@leftoff/cli 0.8.0 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -6
- package/README.md +43 -16
- package/assets/sample-transcript.jsonl +11 -11
- package/bin/leftoff.js +315 -304
- package/docs/SECURITY.md +152 -0
- package/docs/agent-feed-hosting.md +104 -0
- package/docs/agent-feed.example.json +103 -0
- package/docs/agent-feed.md +139 -0
- package/docs/peer-protocol.md +136 -0
- package/hooks/README.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +33 -3
- package/mod/hooks/register.js +33 -5
- package/package.json +2 -1
- package/ui/app.css +80 -0
- package/ui/app.js +245 -28
- package/ui/changelog-md.js +5 -2
- package/ui/prs.js +188 -24
- package/ui/repos.js +24 -2
- package/ui/viewer.js +51 -0
package/ui/app.js
CHANGED
|
@@ -4,11 +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 { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
|
|
7
|
+
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst } from './repos.js'
|
|
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
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
|
+
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers } from './viewer.js'
|
|
12
13
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
13
14
|
const $app = document.getElementById('app')
|
|
14
15
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
@@ -158,7 +159,7 @@ function where(s) {
|
|
|
158
159
|
const branch = s.repo && s.repo.branch
|
|
159
160
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
160
161
|
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>' + wtChips(s) +
|
|
161
|
-
(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)
|
|
162
|
+
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<span class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</span>' })() : '') + modelChip(s)
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
@@ -187,6 +188,7 @@ function banners() {
|
|
|
187
188
|
(items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
|
|
188
189
|
'<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
|
|
189
190
|
}
|
|
191
|
+
out += syncBanners(h)
|
|
190
192
|
const mod = h.mod
|
|
191
193
|
if (mod && mod.failed && mod.failed.length) {
|
|
192
194
|
out += '<div class="banner warn" role="status"><b>Some mod commands didn\'t register</b> in the last Claude Code session' + (mod.machine ? ' on ' + esc(mod.machine) : '') + ': ' +
|
|
@@ -195,6 +197,65 @@ function banners() {
|
|
|
195
197
|
return out
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
|
|
201
|
+
// 0.8.3: a low disk on this Mac (before Syncthing stops), sync that has
|
|
202
|
+
// actually stopped somewhere (a Needs-style notice), and "Set up this Mac too"
|
|
203
|
+
// for a desktop browser on another computer.
|
|
204
|
+
function syncBanners(h) {
|
|
205
|
+
let out = ''
|
|
206
|
+
const sy = h.sync || {}
|
|
207
|
+
const d = sy.disk
|
|
208
|
+
const stopped = (sy.items || []).filter((i) => i.level === 'red' && /stopped|full/i.test(i.text))
|
|
209
|
+
if (stopped.length) {
|
|
210
|
+
const mine = d && d.level === 'red'
|
|
211
|
+
out += '<div class="banner needs-style sync-stopped" role="alert"><div class="grow">' + stopped.map((i) => '<div><b>' + esc(i.text) + '</b>' + (i.fix ? '<div class="fixline">fix: ' + esc(i.fix) + '</div>' : '') + '</div>').join('') + '</div>' + (mine && d.fix && d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
|
|
212
|
+
}
|
|
213
|
+
if (d && d.level === 'warn') {
|
|
214
|
+
out += '<div class="banner warn lowdisk" role="status"><div class="grow"><b>' + esc(d.text) + '</b><div class="fixline">fix: ' + esc(d.fix.text) + '</div></div>' + (d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '" title="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
|
|
215
|
+
}
|
|
216
|
+
const v = h.viewer || {}
|
|
217
|
+
const who = { viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() }
|
|
218
|
+
// Ask this computer's own Leftoff once; the banner waits for the answer.
|
|
219
|
+
if (setupCandidate(who) && state.localHere === undefined) {
|
|
220
|
+
state.localHere = null
|
|
221
|
+
localLeftoffAnswers().then((here) => { state.localHere = here; if (!here) render() })
|
|
222
|
+
}
|
|
223
|
+
if (wantsSetupBanner({ ...who, here: state.localHere })) {
|
|
224
|
+
const su = v.setup
|
|
225
|
+
out += '<div class="banner info setup-mac" role="status"><div class="grow">This board is running on <b>' + esc(su.machine || 'another Mac') + '</b>. To track sessions on this computer too, run:<div class="joinline"><code class="cmd">' + esc(su.command) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(su.command) + '">Copy</button></div></div>' +
|
|
226
|
+
'<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
const SETUP_KEY = 'lo.setupDismissed'
|
|
231
|
+
function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY) === '1' } catch { return false } }
|
|
232
|
+
|
|
233
|
+
// The board's sync chip: quiet when all's well, amber or red when Syncthing
|
|
234
|
+
// reports an error, a disk runs low, or another Mac has been away 15+ min.
|
|
235
|
+
function syncChip() {
|
|
236
|
+
const sy = (state.health && state.health.sync) || null
|
|
237
|
+
if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
|
|
238
|
+
return '<button type="button" class="chip syncchip ' + esc(sy.level) + '" data-action="sync-details" aria-expanded="' + !!state.syncOpen + '" title="Sync status · tap for details">' + '<span class="sdot" aria-hidden="true"></span>' + esc(sy.label) + '</button>'
|
|
239
|
+
}
|
|
240
|
+
function syncDetails() {
|
|
241
|
+
const sy = (state.health && state.health.sync) || null
|
|
242
|
+
if (!state.syncOpen || !sy || !(sy.items || []).length) return ''
|
|
243
|
+
return '<div class="syncdetails" role="region" aria-label="Sync details">' + sy.items.map((i) => '<div class="syncitem ' + esc(i.level) + '"><div><b>' + esc(i.text) + '</b></div>' + (i.fix ? '<div class="fixline">fix: ' + esc(i.fix) + '</div>' : '') + (i.cmd ? '<div class="joinline"><code class="cmd">' + esc(i.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(i.cmd) + '">Copy</button></div>' : '') + '</div>').join('') +
|
|
244
|
+
'<div class="hint">More in <a href="#/settings/sync">Settings → Sync</a> or <code>leftoff doctor</code>.</div></div>'
|
|
245
|
+
}
|
|
246
|
+
// Another Mac whose data is stale: "showing data as of N min ago".
|
|
247
|
+
function staleLine(sessions) {
|
|
248
|
+
const st = state.health && state.health.sync && state.health.sync.stale
|
|
249
|
+
if (!st) return ''
|
|
250
|
+
const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
|
|
251
|
+
return ms.length ? '<div class="stale-line faint">' + ms.map((m) => esc(m) + ': showing data as of ' + esc(st[m] ? ago(st[m]) : 'its last sync')).join(' · ') + '</div>' : ''
|
|
252
|
+
}
|
|
253
|
+
function staleNote(machine) {
|
|
254
|
+
const st = state.health && state.health.sync && state.health.sync.stale
|
|
255
|
+
if (!st || !machine || !(machine in st)) return ''
|
|
256
|
+
return st[machine] ? 'as of ' + ago(st[machine]) : 'not synced yet'
|
|
257
|
+
}
|
|
258
|
+
|
|
198
259
|
function updateLine() {
|
|
199
260
|
const h = state.health || {}
|
|
200
261
|
const u = h.update || {}
|
|
@@ -227,6 +288,16 @@ async function viewWhatsNew() {
|
|
|
227
288
|
}
|
|
228
289
|
}
|
|
229
290
|
|
|
291
|
+
// A doc from the installed package (0.8.1): #/docs/security, read through
|
|
292
|
+
// GET /v1/docs/<name> (also from a phone, through the relay) and shown with the
|
|
293
|
+
// viewer's escaped Markdown.
|
|
294
|
+
async function viewDocPage(name) {
|
|
295
|
+
const d = await api('GET', '/v1/docs/' + encodeURIComponent(name))
|
|
296
|
+
$app.innerHTML = '<div class="pagehead"><div><h1>' + esc(d.title || name) + '</h1><div class="sub"><a href="#/settings/' + (/^agent-feed/.test(name) ? 'agents' : 'security') + '">← Settings</a></div></div></div>' +
|
|
297
|
+
'<div class="doc-page">' + docViewerBody(d) + '</div>'
|
|
298
|
+
window.scrollTo(0, 0)
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
function wnCard(e, { current = false, coming = false } = {}) {
|
|
231
302
|
return '<article class="wn-card' + (e.isNew ? ' is-new' : '') + (coming ? ' coming' : '') + '">' +
|
|
232
303
|
'<header><span class="wn-ver">v' + esc(e.version) + '</span>' + (e.date ? '<span class="wn-date">' + esc(e.date) + '</span>' : '') +
|
|
@@ -371,8 +442,11 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
371
442
|
if (chips.length < 2 && !selected.length) return ''
|
|
372
443
|
// A repo named in the link but with nothing here still gets a chip, so it can be cleared.
|
|
373
444
|
for (const tok of selected) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0, missing: true })
|
|
445
|
+
// 0.8.3: a chosen repo always shows, first in the row, removable (✕), even
|
|
446
|
+
// with nothing in this view: a filter is never active out of sight.
|
|
447
|
+
chips = orderSelectedFirst(chips, selected)
|
|
374
448
|
const { shown: row, more } = splitChips(chips, selected, 8)
|
|
375
|
-
const chip = (c) => '<button type="button" class="rchip' + (
|
|
449
|
+
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
376
450
|
const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
|
|
377
451
|
let menu = ''
|
|
378
452
|
if (more.length) {
|
|
@@ -386,8 +460,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
386
460
|
async function viewBoard() {
|
|
387
461
|
if (!state.health) await refreshHealth()
|
|
388
462
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
389
|
-
const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
390
|
-
|
|
463
|
+
const { sessions: all, viewer, closedCount, todoCount, parked = [] } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
464
|
+
// 0.8.2: parked sessions have their own section (any age), not a status group
|
|
465
|
+
const sessions = all.filter((s) => !s.parked)
|
|
466
|
+
state.sessions = all.concat(parked.filter((p) => !all.some((s) => s.id === p.id)))
|
|
391
467
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
392
468
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
393
469
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
@@ -420,11 +496,12 @@ async function viewBoard() {
|
|
|
420
496
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
421
497
|
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
422
498
|
const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
|
|
423
|
-
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
|
|
499
|
+
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>' + syncChip() + '</div>' +
|
|
424
500
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
425
501
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
426
502
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
427
503
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
504
|
+
html += syncDetails() + staleLine(sessions)
|
|
428
505
|
html += restoreBanner()
|
|
429
506
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
430
507
|
const needs = needsItems(sessions)
|
|
@@ -440,7 +517,7 @@ async function viewBoard() {
|
|
|
440
517
|
return closedWire()
|
|
441
518
|
}
|
|
442
519
|
html += repoRow(inSpace, f)
|
|
443
|
-
if (!shown.length && !empties.length) {
|
|
520
|
+
if (!shown.length && !empties.length && !parked.length) {
|
|
444
521
|
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>'
|
|
445
522
|
} else if (!list.length && shown.length) {
|
|
446
523
|
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
@@ -450,6 +527,7 @@ async function viewBoard() {
|
|
|
450
527
|
html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
451
528
|
}
|
|
452
529
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
530
|
+
if (!filter) html += parkedSection(applyFilter(sp ? parked.filter((s) => s.space === sp) : parked, { repos: f.repos }), viewer || {})
|
|
453
531
|
// 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
|
|
454
532
|
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
455
533
|
$app.innerHTML = banners() + html
|
|
@@ -478,7 +556,7 @@ async function closedFetch(f, sp) {
|
|
|
478
556
|
}
|
|
479
557
|
async function closedSection(f, sp) {
|
|
480
558
|
const r = await closedFetch(f, sp)
|
|
481
|
-
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
559
|
+
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.allTotal != null ? r.allTotal : r.total, 'nothing closed yet') +
|
|
482
560
|
'<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>' +
|
|
483
561
|
'<button type="button" class="chip docs-filter" data-action="closed-docs" aria-pressed="' + closedUi.docs + '" title="Only sessions that made docs, plans or artifacts">' + (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts</button></div>' +
|
|
484
562
|
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
@@ -567,6 +645,49 @@ async function resumeOpen(btn, id) {
|
|
|
567
645
|
}
|
|
568
646
|
}
|
|
569
647
|
|
|
648
|
+
// Parked (0.8.2): sessions you left meaning to come back to (/cp-park, or Park
|
|
649
|
+
// on a session's page). Its own small section under the board, any age, not
|
|
650
|
+
// counted in Needs you. Resume is the main button once it's closed; while
|
|
651
|
+
// it's still open, Go. "Done with it" sends it to Closed.
|
|
652
|
+
function parkedSection(list, viewer) {
|
|
653
|
+
if (!list.length) return ''
|
|
654
|
+
return '<section class="parked-sec mt" aria-label="Parked"><h2 class="parked-h">Parked <span class="count">' + list.length + '</span></h2>' +
|
|
655
|
+
'<div class="grid">' + list.map((s) => parkedCard(s, viewer)).join('') + '</div></section>'
|
|
656
|
+
}
|
|
657
|
+
function parkedCard(s, viewer) {
|
|
658
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
659
|
+
const branch = s.repo && s.repo.branch
|
|
660
|
+
const label = s.repoLabel || s.repoName || 'no repo'
|
|
661
|
+
const p = s.parked || {}
|
|
662
|
+
const open = s.status !== 'ended' && s.live !== 'closed'
|
|
663
|
+
const cp = s.latestCheckpoint
|
|
664
|
+
const note = p.note || (cp && cp.next) || ''
|
|
665
|
+
const main = open ? goButton(s) : resumeButton(s, viewer)
|
|
666
|
+
return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
667
|
+
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
|
|
668
|
+
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
669
|
+
'<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>' + wtChips(s) +
|
|
670
|
+
(s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
|
|
671
|
+
'<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
|
|
672
|
+
(note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
|
|
673
|
+
'<div class="actions-row">' + main +
|
|
674
|
+
(open ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>') +
|
|
675
|
+
'<button type="button" class="linkish park-done" data-action="unpark" data-id="' + esc(s.id) + '" title="No longer parked' + (open ? '' : '; it goes to Closed') + '">Done with it</button>' +
|
|
676
|
+
'</div></article>'
|
|
677
|
+
}
|
|
678
|
+
// The session page's Park row (0.8.2): any open session, Codex too, with an
|
|
679
|
+
// optional note that becomes the checkpoint's Next line. Parked: the note and Done.
|
|
680
|
+
function parkRow(s) {
|
|
681
|
+
if (s.agent) return ''
|
|
682
|
+
if (s.parked) return '<section class="park-row is-on" id="park"><div class="park-on"><span class="pill s-parked">parked</span> <span class="faint">' + esc(ago(s.parked.at)) + '</span>' + (s.parked.note ? ' <span class="park-note-inline">' + esc(s.parked.note) + '</span>' : '') + '</div>' +
|
|
683
|
+
'<button type="button" class="btn sm" data-action="unpark" data-id="' + esc(s.id) + '">Done with it</button></section>'
|
|
684
|
+
if (s.status === 'ended' || s.live === 'closed' || s.empty) return ''
|
|
685
|
+
return '<section class="park-row" id="park"><form class="park-form" data-park="' + esc(s.id) + '" autocomplete="off">' +
|
|
686
|
+
'<input type="text" name="note" maxlength="300" placeholder="Note for later (optional)" aria-label="Park note" enterkeyhint="done">' +
|
|
687
|
+
'<button type="submit" class="btn sm">Park</button></form>' +
|
|
688
|
+
'<div class="faint park-hint">Checkpoints it, and keeps it under Parked when it closes.' + (s.codingAgent === 'codex' ? '' : ' In the session: /cp-park [note]') + '</div></section>'
|
|
689
|
+
}
|
|
690
|
+
|
|
570
691
|
// The folded row of empty sessions (v0.4.8), closed by default.
|
|
571
692
|
function emptyFold(list) {
|
|
572
693
|
if (!list.length) return ''
|
|
@@ -771,6 +892,7 @@ async function viewSession(id) {
|
|
|
771
892
|
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) +
|
|
772
893
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
773
894
|
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
895
|
+
html += parkRow(s)
|
|
774
896
|
html += spacePicker(s, spv, 'session', s.id)
|
|
775
897
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
776
898
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
@@ -817,30 +939,75 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
|
|
|
817
939
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
818
940
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
819
941
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
820
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
942
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, view: 'mine', filter: {} }
|
|
943
|
+
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
944
|
+
// tap repaints from what we have and reads again only when that is old.
|
|
945
|
+
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen } }
|
|
946
|
+
function paintPrs(d) {
|
|
947
|
+
const q = document.getElementById('prs-q')
|
|
948
|
+
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
949
|
+
$app.innerHTML = banners() + prsPageHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
950
|
+
if (focused) { const n = document.getElementById('prs-q'); if (n) { n.focus(); try { n.setSelectionRange(caret, caret) } catch {} } }
|
|
951
|
+
}
|
|
952
|
+
// Just the chips and the list (typing in the search box keeps its focus).
|
|
953
|
+
function paintPrsBody() {
|
|
954
|
+
const el = document.getElementById('prs-body'), d = prsUi.data[prsUi.view]
|
|
955
|
+
if (el && d) el.innerHTML = prsBodyHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
956
|
+
}
|
|
821
957
|
async function viewPrs() {
|
|
822
958
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
823
959
|
let view = params.get('view')
|
|
824
960
|
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
825
961
|
view = view === 'team' ? 'team' : 'mine'
|
|
826
|
-
|
|
827
|
-
|
|
962
|
+
prsUi.view = view
|
|
963
|
+
prsUi.filter = readPrFilter(params)
|
|
964
|
+
const cached = prsUi.data[view]
|
|
965
|
+
paintPrs(cached || null)
|
|
828
966
|
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
967
|
+
if (cached && !fresh && !cached.loading && !cached.error && Date.now() - (prsUi.at[view] || 0) < 15000) return
|
|
829
968
|
let d
|
|
830
969
|
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
831
970
|
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
832
971
|
if (!(state.route && state.route.name === 'prs')) return
|
|
833
|
-
if (d && d.groups) {
|
|
972
|
+
if (d && (d.rows || d.groups)) {
|
|
834
973
|
// Go / Resume on a row look the session up like a card does.
|
|
835
|
-
for (const
|
|
974
|
+
for (const r of prRows(d)) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
836
975
|
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
837
976
|
}
|
|
838
977
|
prsUi.data[view] = d
|
|
839
|
-
|
|
978
|
+
prsUi.at[view] = Date.now()
|
|
979
|
+
if (prsUi.view === view) paintPrs(d)
|
|
840
980
|
clearTimeout(prsUi.timer)
|
|
841
|
-
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
981
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') { prsUi.at[view] = 0; render() } }, 2500) }
|
|
842
982
|
else if (!(d && d.loading)) prsUi.polls = 0
|
|
843
983
|
}
|
|
984
|
+
// Tap a PR: it opens in place; its details load once (cached ~5 min by the
|
|
985
|
+
// daemon that has gh, a little here too).
|
|
986
|
+
async function togglePr(url, { retry = false } = {}) {
|
|
987
|
+
if (!url) return
|
|
988
|
+
const was = prsUi.open.has(url)
|
|
989
|
+
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
990
|
+
const repaint = () => {
|
|
991
|
+
const d = prsUi.data[prsUi.view]
|
|
992
|
+
const r = prRows(d).find((x) => x.pr.url === url)
|
|
993
|
+
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
994
|
+
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
995
|
+
}
|
|
996
|
+
const have = prsUi.details[url]
|
|
997
|
+
if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
|
|
998
|
+
prsUi.details[url] = { loading: true }
|
|
999
|
+
repaint()
|
|
1000
|
+
let res
|
|
1001
|
+
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1002
|
+
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1003
|
+
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1004
|
+
if (state.route && state.route.name === 'prs') repaint()
|
|
1005
|
+
}
|
|
1006
|
+
function setPrsFilter(f) {
|
|
1007
|
+
const h = prsHash(prsUi.view, f)
|
|
1008
|
+
if (location.hash !== h) location.hash = h
|
|
1009
|
+
}
|
|
1010
|
+
|
|
844
1011
|
function prSessionAction(s) {
|
|
845
1012
|
if (s.live === 'closed' || s.status === 'ended') {
|
|
846
1013
|
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
@@ -1582,6 +1749,8 @@ function settingsDots() {
|
|
|
1582
1749
|
if ((st.knocked || []).length) add('sync', 'warn', 'A Mac asked to connect without the pairing proof')
|
|
1583
1750
|
}
|
|
1584
1751
|
if (st && st.run && st.run.state === 'failed') add('sync', 'warn', 'The last Syncthing step failed')
|
|
1752
|
+
const sy = state.health && state.health.sync
|
|
1753
|
+
if (sy && sy.level !== 'ok' && (sy.items || []).length) add('sync', 'warn', sy.items[0].text) // 0.8.3: low disk, a stop, a quiet Mac
|
|
1585
1754
|
const peers = syncUi.peers && syncUi.peers.peers
|
|
1586
1755
|
if (peers && peers.some((x) => x.trust === 'key_changed')) add('security', 'warn', 'A machine’s key changed')
|
|
1587
1756
|
const ag = agentUi.data && agentUi.data.agents
|
|
@@ -1735,9 +1904,9 @@ async function viewSettings() {
|
|
|
1735
1904
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
1736
1905
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
1737
1906
|
'<div class="section">Your data</div>' +
|
|
1738
|
-
'<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '"
|
|
1907
|
+
'<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '">Security notes</a></div>'),
|
|
1739
1908
|
updates: '<div class="section">About</div>' +
|
|
1740
|
-
'<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="' +
|
|
1909
|
+
'<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="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
|
|
1741
1910
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
1742
1911
|
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>' +
|
|
1743
1912
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
@@ -1812,8 +1981,10 @@ function syncSection(d, st) {
|
|
|
1812
1981
|
|
|
1813
1982
|
// ------------------------------------------------------------ Settings → Agents (v0.5.4)
|
|
1814
1983
|
|
|
1815
|
-
|
|
1816
|
-
|
|
1984
|
+
// 0.8.1: the repo is private, so About links to the public npm page and the
|
|
1985
|
+
// docs open in the board, served from the installed package (src/docs.js).
|
|
1986
|
+
const NPM_URL = 'https://www.npmjs.com/package/@leftoff/cli'
|
|
1987
|
+
const AGENT_HOSTING_URL = '#/docs/agent-feed-hosting'
|
|
1817
1988
|
const agentUi = { data: null, err: null, busy: false }
|
|
1818
1989
|
function agentStatusLine(a) {
|
|
1819
1990
|
const st = a.status || {}
|
|
@@ -1839,7 +2010,7 @@ function agentsSectionHtml() {
|
|
|
1839
2010
|
const local = d.viewer && d.viewer.local
|
|
1840
2011
|
let h = '<div class="section dup" id="agents">Agents</div>' +
|
|
1841
2012
|
'<div class="full hint">Work you do with an AI assistant outside Claude Code (Tab, and later others) shows up on the board like another machine. The agent publishes a private feed; ' + (d.agents.length ? 'one Mac pulls it every 2 minutes and the sessions sync to the others as Markdown in <code>agents/</code>.' : 'one Mac pulls it every 2 minutes. Its sessions, “needs you” items and lists show up read-only.') +
|
|
1842
|
-
' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '"
|
|
2013
|
+
' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '">How to host a feed</a></div>'
|
|
1843
2014
|
h += '<div class="full agents">' + (d.agents.length ? d.agents.map((a) => agentRow(a, local)).join('') : '<div class="nocp">No agent feeds yet.</div>') + '</div>'
|
|
1844
2015
|
if (local) {
|
|
1845
2016
|
h += '<div class="full agentadd"><div class="ah"><strong>Add agent feed</strong></div>' +
|
|
@@ -2042,7 +2213,7 @@ async function syncAction(action, btn) {
|
|
|
2042
2213
|
// pair another Mac with a one-time code, join with a code, status, stop. All
|
|
2043
2214
|
// changes are loopback only; a phone sees the status and nothing else.
|
|
2044
2215
|
|
|
2045
|
-
const SECURITY_URL =
|
|
2216
|
+
const SECURITY_URL = '#/docs/security'
|
|
2046
2217
|
const PROTECT = {
|
|
2047
2218
|
syncthing: ['Device to device, encrypted in transit (TLS). No cloud copy.', 'Relays and discovery servers only see encrypted traffic and which devices connect.', 'Your board token, phone sign-ins and API keys stay on each Mac.', 'Only the Mac that uses your one-time code is accepted.'],
|
|
2048
2219
|
icloud: ['A copy lives on Apple’s servers, under Apple’s terms.', 'With Advanced Data Protection on, iCloud Drive is end-to-end encrypted. Without it, Apple holds the keys.', 'API keys and machine keys stay on each Mac. The board token and phone sign-in records (hashes only) sync with the folder.', 'Choose “Keep Downloaded” so files stay on disk.'],
|
|
@@ -2053,7 +2224,7 @@ const PROTECT = {
|
|
|
2053
2224
|
function protectHtml(kind, { open = false } = {}) {
|
|
2054
2225
|
const items = PROTECT[kind] || PROTECT.other
|
|
2055
2226
|
return '<details class="protect"' + (open ? ' open' : '') + '><summary>' + icon('check') + 'How your data is protected</summary><ul>' + items.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul>' +
|
|
2056
|
-
'<a href="' + SECURITY_URL + '"
|
|
2227
|
+
'<a href="' + SECURITY_URL + '">The full security notes</a></details>'
|
|
2057
2228
|
}
|
|
2058
2229
|
|
|
2059
2230
|
const stUi = { data: null, panel: null, pair: null, joinCode: '', path: '', timer: null, busy: false, error: null, showLog: false }
|
|
@@ -2070,7 +2241,7 @@ function stSection(d) {
|
|
|
2070
2241
|
|
|
2071
2242
|
function stStatusLine(d) {
|
|
2072
2243
|
if (!d.installed) return '<span class="chip">not installed</span>'
|
|
2073
|
-
if (!d.running) return '<span class="chip warn">installed · not running</span>'
|
|
2244
|
+
if (!d.running) return '<span class="chip warn">installed · not running</span>' + (d.fixes && d.fixes.notRunning ? '<span class="fixline">fix: ' + esc(d.fixes.notRunning) + '</span>' : '')
|
|
2074
2245
|
const f = d.folder
|
|
2075
2246
|
let h = '<span class="chip ok">' + icon('check') + 'running' + (d.version ? ' · ' + esc(d.version) : '') + '</span>'
|
|
2076
2247
|
if (f) {
|
|
@@ -2085,7 +2256,7 @@ function stPeersHtml(d) {
|
|
|
2085
2256
|
if (!d.folder) return ''
|
|
2086
2257
|
if (!d.peers.length) return '<div class="hint">No other Mac yet.</div>'
|
|
2087
2258
|
return '<div class="stpeers">' + d.peers.map((p) => '<div class="stpeer"><span class="chip machine remote">⌂ ' + esc(p.name) + '</span>' +
|
|
2088
|
-
(p.connected ? '<span class="chip ok">' + icon('check') + 'connected' + (p.completion != null && p.completion < 100 ? ' · ' + esc(p.completion) + '%' : '') + '</span>' : p.notSharing ? '<span class="chip warn">not sharing Leftoff with this Mac yet</span>' : '<span class="chip">not connected' + (p.lastSeen ? ' · seen ' + esc(ago(p.lastSeen)) : '') + '</span>') + '</div>').join('') + '</div>'
|
|
2259
|
+
(p.connected ? '<span class="chip ok">' + icon('check') + 'connected' + (p.completion != null && p.completion < 100 ? ' · ' + esc(p.completion) + '%' : '') + '</span>' : p.notSharing ? '<span class="chip warn">not sharing Leftoff with this Mac yet</span>' : '<span class="chip">not connected' + (p.lastSeen ? ' · seen ' + esc(ago(p.lastSeen)) : '') + '</span>') + (!p.connected && !p.notSharing && d.fixes && d.fixes.peers && d.fixes.peers[p.name] ? '<div class="fixline">fix: ' + esc(d.fixes.peers[p.name]) + '</div>' : '') + '</div>').join('') + '</div>'
|
|
2089
2260
|
}
|
|
2090
2261
|
|
|
2091
2262
|
// A Mac that knocked during a pairing without the proof (never auto-accepted).
|
|
@@ -2156,7 +2327,7 @@ function stBoxHtml(d) {
|
|
|
2156
2327
|
let h = '<div class="ststatus">' + stStatusLine(d) + (d.folder ? '<span class="chip mono">' + esc(d.folder.display) + '</span>' : '') + '</div>'
|
|
2157
2328
|
if (d.folder) h += stPeersHtml(d)
|
|
2158
2329
|
h += stKnockedHtml(d, local)
|
|
2159
|
-
|
|
2330
|
+
;((d.folder && d.folder.errors) || []).forEach((e, i) => { const fx = d.fixes && d.fixes.errors && d.fixes.errors[i]; h += '<div class="warnbox">' + esc((e.path ? e.path + ': ' : '') + e.error) + (fx ? '<div class="fixline">fix: ' + esc(fx) + '</div>' : '') + '</div>' })
|
|
2160
2331
|
if (!local) return h + '<div class="hint">Installing, pairing and joining happen on the Mac itself.</div>'
|
|
2161
2332
|
const run = d.run && Date.now() - new Date(d.run.at).getTime() < 3600000 ? d.run : null
|
|
2162
2333
|
const running = run && !['done', 'failed'].includes(run.state)
|
|
@@ -2448,6 +2619,7 @@ async function render() {
|
|
|
2448
2619
|
else if (r.name === 'lists') await viewLists(r.arg)
|
|
2449
2620
|
else if (r.name === 'settings') await viewSettings()
|
|
2450
2621
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
2622
|
+
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
2451
2623
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
2452
2624
|
else await viewBoard()
|
|
2453
2625
|
} catch (e) {
|
|
@@ -2719,6 +2891,25 @@ document.addEventListener('click', (ev) => {
|
|
|
2719
2891
|
location.hash = c.dataset.href
|
|
2720
2892
|
})
|
|
2721
2893
|
|
|
2894
|
+
// 0.8.5: a tap anywhere on a PR row (not a link or button) opens it in place.
|
|
2895
|
+
document.addEventListener('click', (ev) => {
|
|
2896
|
+
const row = ev.target.closest('article.prrow[data-pr]')
|
|
2897
|
+
if (!row || ev.defaultPrevented || ev.target.closest('a, button, input, textarea, label, summary, details.pd-body, [data-action]')) return
|
|
2898
|
+
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
2899
|
+
togglePr(row.dataset.pr)
|
|
2900
|
+
})
|
|
2901
|
+
document.addEventListener('toggle', (ev) => { if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open }, true)
|
|
2902
|
+
let prsQTimer = null
|
|
2903
|
+
document.addEventListener('input', (ev) => {
|
|
2904
|
+
if (ev.target.id !== 'prs-q') return
|
|
2905
|
+
prsUi.filter = { ...prsUi.filter, q: ev.target.value }
|
|
2906
|
+
paintPrsBody()
|
|
2907
|
+
// The link follows without a history entry per keystroke.
|
|
2908
|
+
clearTimeout(prsQTimer)
|
|
2909
|
+
prsQTimer = setTimeout(() => { const h = prsHash(prsUi.view, prsUi.filter); if (location.hash !== h) history.replaceState(null, '', h) }, 250)
|
|
2910
|
+
})
|
|
2911
|
+
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
2912
|
+
|
|
2722
2913
|
document.addEventListener('click', async (ev) => {
|
|
2723
2914
|
const btn = ev.target.closest('[data-action]')
|
|
2724
2915
|
if (!btn) return
|
|
@@ -2732,7 +2923,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
2732
2923
|
try {
|
|
2733
2924
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2734
2925
|
// PRs (0.6.9)
|
|
2735
|
-
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v
|
|
2926
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
|
|
2927
|
+
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
2928
|
+
if (action === 'prs-filter') {
|
|
2929
|
+
const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
|
|
2930
|
+
if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
|
|
2931
|
+
else if (k === 'state' || k === 'checks') f[k] = f[k] === v ? null : v
|
|
2932
|
+
return setPrsFilter(f)
|
|
2933
|
+
}
|
|
2934
|
+
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
2935
|
+
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
2936
|
+
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
2736
2937
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2737
2938
|
if (action === 'pr-unlink') {
|
|
2738
2939
|
btn.disabled = true
|
|
@@ -2868,6 +3069,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
2868
3069
|
if (!confirm('Sign out every remote browser, the home-screen app included? Each needs a new QR code or code.')) return
|
|
2869
3070
|
const r = await api('POST', '/v1/remote/revoke'); toast('Signed out ' + r.revoked + ' browser' + (r.revoked === 1 ? '' : 's')); return render()
|
|
2870
3071
|
}
|
|
3072
|
+
if (action === 'dismiss-setup') { try { window.localStorage.setItem(SETUP_KEY, '1') } catch {} return render() }
|
|
3073
|
+
if (action === 'sync-details') { state.syncOpen = !state.syncOpen; return render() }
|
|
2871
3074
|
if (action === 'dismiss-upgrade') { await api('POST', '/v1/changelog/dismiss'); await refreshHealth(); return render() }
|
|
2872
3075
|
if (action === 'agent-add') return agentAdd(btn)
|
|
2873
3076
|
if (action === 'agent-refresh') {
|
|
@@ -3001,7 +3204,12 @@ document.addEventListener('click', async (ev) => {
|
|
|
3001
3204
|
return render()
|
|
3002
3205
|
}
|
|
3003
3206
|
if (action === 'go') return goClick(btn, id, s)
|
|
3004
|
-
if (action === 'resume-open')
|
|
3207
|
+
if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
|
|
3208
|
+
if (action === 'unpark') {
|
|
3209
|
+
btn.disabled = true
|
|
3210
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast(r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
3211
|
+
return render()
|
|
3212
|
+
}
|
|
3005
3213
|
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 }
|
|
3006
3214
|
// 0.7.0: only sessions with docs, plans or artifacts
|
|
3007
3215
|
if (action === 'closed-docs') { closedUi.docs = !closedUi.docs; closedUi.limit = CLOSED_PAGE; btn.setAttribute('aria-pressed', String(closedUi.docs)); btn.textContent = (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts'; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
|
|
@@ -3246,6 +3454,14 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3246
3454
|
try { await api('POST', '/v1/todos', { text, sessionId: sid }); input.value = ''; input.blur(); toast('Added to your to-dos'); await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
|
|
3247
3455
|
return
|
|
3248
3456
|
}
|
|
3457
|
+
if (ev.target.dataset && ev.target.dataset.park) {
|
|
3458
|
+
ev.preventDefault()
|
|
3459
|
+
const input = ev.target.querySelector('input[name=note]')
|
|
3460
|
+
const note = input.value.trim()
|
|
3461
|
+
const b = ev.target.querySelector('button'); if (b) { b.disabled = true; b.textContent = 'Parking…' }
|
|
3462
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(ev.target.dataset.park) + '/park', { note: note || null }, { timeoutMs: 90000 }); input.blur(); toast('Parked' + (r.checkpoint ? ' · Next: ' + r.checkpoint.next : ''), 6000); await render() } catch (e) { toast(e.message, 5000) } finally { if (b && b.isConnected) { b.disabled = false; b.textContent = 'Park' } }
|
|
3463
|
+
return
|
|
3464
|
+
}
|
|
3249
3465
|
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3250
3466
|
ev.preventDefault()
|
|
3251
3467
|
const input = ev.target.querySelector('input[name=ref]')
|
|
@@ -3320,6 +3536,7 @@ let stream = null
|
|
|
3320
3536
|
function connect() {
|
|
3321
3537
|
if (stream) { try { stream.close() } catch {} }
|
|
3322
3538
|
const es = stream = new EventSource('/v1/stream')
|
|
3539
|
+
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
3323
3540
|
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)
|
|
3324
3541
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3325
3542
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
package/ui/changelog-md.js
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Links: CHANGELOG.md ships inside the npm package and the repo is private,
|
|
7
7
|
// so only an absolute https link becomes a real link (new tab). A relative
|
|
8
|
-
// link (docs/relay.md, README.md#…) or any other scheme shows as its text
|
|
8
|
+
// link (docs/relay.md, README.md#…) or any other scheme shows as its text,
|
|
9
|
+
// except a #/docs/<name> link to a doc the board itself serves (0.8.1).
|
|
9
10
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
10
11
|
|
|
11
12
|
const httpsUrl = (u) => /^https:\/\/[^\s"'<>]+$/i.test(String(u || '').trim())
|
|
12
13
|
const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel="noopener">' + inner + '</a>'
|
|
14
|
+
// 0.8.1: a link to one of the docs the board serves (#/docs/security) opens in the board.
|
|
15
|
+
const docLink = (u) => /^#\/docs\/[a-z-]+$/.test(String(u || '').trim())
|
|
13
16
|
|
|
14
17
|
// One line of inline markdown -> HTML. Everything that isn't markup is escaped.
|
|
15
18
|
export function mdInline(s, { links = true } = {}) {
|
|
@@ -23,7 +26,7 @@ export function mdInline(s, { links = true } = {}) {
|
|
|
23
26
|
// [text](url "title"): a real link only for https; otherwise just the text.
|
|
24
27
|
t = t.replace(/\[([^\]]+)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/g, (_, text, url) => {
|
|
25
28
|
const inner = back(marks(text))
|
|
26
|
-
return hold(links && httpsUrl(url) ? anchor(url, inner) : inner)
|
|
29
|
+
return hold(links && httpsUrl(url) ? anchor(url, inner) : links && docLink(url) ? '<a href="' + esc(url.trim()) + '">' + inner + '</a>' : inner)
|
|
27
30
|
})
|
|
28
31
|
t = marks(t)
|
|
29
32
|
if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&/g, '&'), url))
|