@leftoff/cli 0.7.1 → 0.8.3
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 +32 -6
- package/README.md +43 -16
- package/bin/leftoff.js +305 -283
- 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 +43 -7
- package/mod/hooks/register.js +33 -5
- package/package.json +2 -1
- package/ui/app.css +45 -0
- package/ui/app.js +163 -22
- package/ui/changelog-md.js +5 -2
- package/ui/needs.js +2 -2
- package/ui/repos.js +24 -2
- package/ui/viewer.js +19 -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
11
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
12
|
+
import { wantsSetupBanner } 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: {} }
|
|
@@ -139,10 +140,15 @@ function agentChip(a, { title } = {}) {
|
|
|
139
140
|
return '<span class="chip machine agent' + (a.ok === false ? ' stale' : '') + '" title="' + esc(title || ('external agent · ' + (a.pullsHere ? 'pulled by this Mac' : 'pulled by ' + (a.puller || 'another Mac')) + (a.lastSuccessAt ? ' · last pull ' + ago(a.lastSuccessAt) : ''))) + '">' + esc(a.icon || '◆') + ' ' + esc(a.display || a.name) + '</span>'
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
// 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
|
|
144
|
+
function codexBadge(s) {
|
|
145
|
+
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" title="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
|
|
146
|
+
}
|
|
147
|
+
|
|
142
148
|
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
143
149
|
// where its edits went says where it started.
|
|
144
150
|
function wtChips(s) {
|
|
145
|
-
let h =
|
|
151
|
+
let h = codexBadge(s)
|
|
146
152
|
if (s.worktree && s.worktree.name) h += '<span class="chip mono wt" title="' + esc('git worktree · ' + (s.worktree.path || s.worktree.name)) + '">⑂ ' + esc(s.worktree.name) + '</span>'
|
|
147
153
|
if (s.startedIn) h += '<span class="chip started-in" title="' + esc('Started in ' + s.startedIn + '; its edits went to ' + (s.repoLabel || s.repoName || 'this repo')) + '">started in ' + esc(s.startedIn) + '</span>'
|
|
148
154
|
return h
|
|
@@ -153,7 +159,7 @@ function where(s) {
|
|
|
153
159
|
const branch = s.repo && s.repo.branch
|
|
154
160
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
155
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) +
|
|
156
|
-
(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)
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
@@ -182,6 +188,7 @@ function banners() {
|
|
|
182
188
|
(items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
|
|
183
189
|
'<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
|
|
184
190
|
}
|
|
191
|
+
out += syncBanners(h)
|
|
185
192
|
const mod = h.mod
|
|
186
193
|
if (mod && mod.failed && mod.failed.length) {
|
|
187
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) : '') + ': ' +
|
|
@@ -190,6 +197,59 @@ function banners() {
|
|
|
190
197
|
return out
|
|
191
198
|
}
|
|
192
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
|
+
if (wantsSetupBanner({ viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() })) {
|
|
218
|
+
const su = v.setup
|
|
219
|
+
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>' +
|
|
220
|
+
'<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
const SETUP_KEY = 'lo.setupDismissed'
|
|
225
|
+
function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY) === '1' } catch { return false } }
|
|
226
|
+
|
|
227
|
+
// The board's sync chip: quiet when all's well, amber or red when Syncthing
|
|
228
|
+
// reports an error, a disk runs low, or another Mac has been away 15+ min.
|
|
229
|
+
function syncChip() {
|
|
230
|
+
const sy = (state.health && state.health.sync) || null
|
|
231
|
+
if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
|
|
232
|
+
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>'
|
|
233
|
+
}
|
|
234
|
+
function syncDetails() {
|
|
235
|
+
const sy = (state.health && state.health.sync) || null
|
|
236
|
+
if (!state.syncOpen || !sy || !(sy.items || []).length) return ''
|
|
237
|
+
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('') +
|
|
238
|
+
'<div class="hint">More in <a href="#/settings/sync">Settings → Sync</a> or <code>leftoff doctor</code>.</div></div>'
|
|
239
|
+
}
|
|
240
|
+
// Another Mac whose data is stale: "showing data as of N min ago".
|
|
241
|
+
function staleLine(sessions) {
|
|
242
|
+
const st = state.health && state.health.sync && state.health.sync.stale
|
|
243
|
+
if (!st) return ''
|
|
244
|
+
const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
|
|
245
|
+
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>' : ''
|
|
246
|
+
}
|
|
247
|
+
function staleNote(machine) {
|
|
248
|
+
const st = state.health && state.health.sync && state.health.sync.stale
|
|
249
|
+
if (!st || !machine || !(machine in st)) return ''
|
|
250
|
+
return st[machine] ? 'as of ' + ago(st[machine]) : 'not synced yet'
|
|
251
|
+
}
|
|
252
|
+
|
|
193
253
|
function updateLine() {
|
|
194
254
|
const h = state.health || {}
|
|
195
255
|
const u = h.update || {}
|
|
@@ -222,6 +282,16 @@ async function viewWhatsNew() {
|
|
|
222
282
|
}
|
|
223
283
|
}
|
|
224
284
|
|
|
285
|
+
// A doc from the installed package (0.8.1): #/docs/security, read through
|
|
286
|
+
// GET /v1/docs/<name> (also from a phone, through the relay) and shown with the
|
|
287
|
+
// viewer's escaped Markdown.
|
|
288
|
+
async function viewDocPage(name) {
|
|
289
|
+
const d = await api('GET', '/v1/docs/' + encodeURIComponent(name))
|
|
290
|
+
$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>' +
|
|
291
|
+
'<div class="doc-page">' + docViewerBody(d) + '</div>'
|
|
292
|
+
window.scrollTo(0, 0)
|
|
293
|
+
}
|
|
294
|
+
|
|
225
295
|
function wnCard(e, { current = false, coming = false } = {}) {
|
|
226
296
|
return '<article class="wn-card' + (e.isNew ? ' is-new' : '') + (coming ? ' coming' : '') + '">' +
|
|
227
297
|
'<header><span class="wn-ver">v' + esc(e.version) + '</span>' + (e.date ? '<span class="wn-date">' + esc(e.date) + '</span>' : '') +
|
|
@@ -366,8 +436,11 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
366
436
|
if (chips.length < 2 && !selected.length) return ''
|
|
367
437
|
// A repo named in the link but with nothing here still gets a chip, so it can be cleared.
|
|
368
438
|
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 })
|
|
439
|
+
// 0.8.3: a chosen repo always shows, first in the row, removable (✕), even
|
|
440
|
+
// with nothing in this view: a filter is never active out of sight.
|
|
441
|
+
chips = orderSelectedFirst(chips, selected)
|
|
369
442
|
const { shown: row, more } = splitChips(chips, selected, 8)
|
|
370
|
-
const chip = (c) => '<button type="button" class="rchip' + (
|
|
443
|
+
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>' }
|
|
371
444
|
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>'
|
|
372
445
|
let menu = ''
|
|
373
446
|
if (more.length) {
|
|
@@ -381,8 +454,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
381
454
|
async function viewBoard() {
|
|
382
455
|
if (!state.health) await refreshHealth()
|
|
383
456
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
384
|
-
const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
385
|
-
|
|
457
|
+
const { sessions: all, viewer, closedCount, todoCount, parked = [] } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
458
|
+
// 0.8.2: parked sessions have their own section (any age), not a status group
|
|
459
|
+
const sessions = all.filter((s) => !s.parked)
|
|
460
|
+
state.sessions = all.concat(parked.filter((p) => !all.some((s) => s.id === p.id)))
|
|
386
461
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
387
462
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
388
463
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
@@ -415,11 +490,12 @@ async function viewBoard() {
|
|
|
415
490
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
416
491
|
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
417
492
|
const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
|
|
418
|
-
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
|
|
493
|
+
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>' +
|
|
419
494
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
420
495
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
421
496
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
422
497
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
498
|
+
html += syncDetails() + staleLine(sessions)
|
|
423
499
|
html += restoreBanner()
|
|
424
500
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
425
501
|
const needs = needsItems(sessions)
|
|
@@ -435,7 +511,7 @@ async function viewBoard() {
|
|
|
435
511
|
return closedWire()
|
|
436
512
|
}
|
|
437
513
|
html += repoRow(inSpace, f)
|
|
438
|
-
if (!shown.length && !empties.length) {
|
|
514
|
+
if (!shown.length && !empties.length && !parked.length) {
|
|
439
515
|
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>'
|
|
440
516
|
} else if (!list.length && shown.length) {
|
|
441
517
|
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
@@ -445,6 +521,7 @@ async function viewBoard() {
|
|
|
445
521
|
html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
446
522
|
}
|
|
447
523
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
524
|
+
if (!filter) html += parkedSection(applyFilter(sp ? parked.filter((s) => s.space === sp) : parked, { repos: f.repos }), viewer || {})
|
|
448
525
|
// 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
|
|
449
526
|
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
450
527
|
$app.innerHTML = banners() + html
|
|
@@ -473,7 +550,7 @@ async function closedFetch(f, sp) {
|
|
|
473
550
|
}
|
|
474
551
|
async function closedSection(f, sp) {
|
|
475
552
|
const r = await closedFetch(f, sp)
|
|
476
|
-
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
553
|
+
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.allTotal != null ? r.allTotal : r.total, 'nothing closed yet') +
|
|
477
554
|
'<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>' +
|
|
478
555
|
'<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>' +
|
|
479
556
|
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
@@ -497,7 +574,7 @@ function resumeButton(s, viewer = {}) {
|
|
|
497
574
|
const busy = closedUi.busy.has(s.id)
|
|
498
575
|
if (s.isLocal) {
|
|
499
576
|
if (!viewer.ghostty) return ''
|
|
500
|
-
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>'
|
|
577
|
+
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: ' + (s.codingAgent === 'codex' ? 'codex resume' : 'claude --resume') + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
|
|
501
578
|
}
|
|
502
579
|
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
503
580
|
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>'
|
|
@@ -562,6 +639,49 @@ async function resumeOpen(btn, id) {
|
|
|
562
639
|
}
|
|
563
640
|
}
|
|
564
641
|
|
|
642
|
+
// Parked (0.8.2): sessions you left meaning to come back to (/cp-park, or Park
|
|
643
|
+
// on a session's page). Its own small section under the board, any age, not
|
|
644
|
+
// counted in Needs you. Resume is the main button once it's closed; while
|
|
645
|
+
// it's still open, Go. "Done with it" sends it to Closed.
|
|
646
|
+
function parkedSection(list, viewer) {
|
|
647
|
+
if (!list.length) return ''
|
|
648
|
+
return '<section class="parked-sec mt" aria-label="Parked"><h2 class="parked-h">Parked <span class="count">' + list.length + '</span></h2>' +
|
|
649
|
+
'<div class="grid">' + list.map((s) => parkedCard(s, viewer)).join('') + '</div></section>'
|
|
650
|
+
}
|
|
651
|
+
function parkedCard(s, viewer) {
|
|
652
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
653
|
+
const branch = s.repo && s.repo.branch
|
|
654
|
+
const label = s.repoLabel || s.repoName || 'no repo'
|
|
655
|
+
const p = s.parked || {}
|
|
656
|
+
const open = s.status !== 'ended' && s.live !== 'closed'
|
|
657
|
+
const cp = s.latestCheckpoint
|
|
658
|
+
const note = p.note || (cp && cp.next) || ''
|
|
659
|
+
const main = open ? goButton(s) : resumeButton(s, viewer)
|
|
660
|
+
return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
661
|
+
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
|
|
662
|
+
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
663
|
+
'<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) +
|
|
664
|
+
(s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
|
|
665
|
+
'<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
|
|
666
|
+
(note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
|
|
667
|
+
'<div class="actions-row">' + main +
|
|
668
|
+
(open ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>') +
|
|
669
|
+
'<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>' +
|
|
670
|
+
'</div></article>'
|
|
671
|
+
}
|
|
672
|
+
// The session page's Park row (0.8.2): any open session, Codex too, with an
|
|
673
|
+
// optional note that becomes the checkpoint's Next line. Parked: the note and Done.
|
|
674
|
+
function parkRow(s) {
|
|
675
|
+
if (s.agent) return ''
|
|
676
|
+
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>' +
|
|
677
|
+
'<button type="button" class="btn sm" data-action="unpark" data-id="' + esc(s.id) + '">Done with it</button></section>'
|
|
678
|
+
if (s.status === 'ended' || s.live === 'closed' || s.empty) return ''
|
|
679
|
+
return '<section class="park-row" id="park"><form class="park-form" data-park="' + esc(s.id) + '" autocomplete="off">' +
|
|
680
|
+
'<input type="text" name="note" maxlength="300" placeholder="Note for later (optional)" aria-label="Park note" enterkeyhint="done">' +
|
|
681
|
+
'<button type="submit" class="btn sm">Park</button></form>' +
|
|
682
|
+
'<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>'
|
|
683
|
+
}
|
|
684
|
+
|
|
565
685
|
// The folded row of empty sessions (v0.4.8), closed by default.
|
|
566
686
|
function emptyFold(list) {
|
|
567
687
|
if (!list.length) return ''
|
|
@@ -765,7 +885,8 @@ async function viewSession(id) {
|
|
|
765
885
|
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
766
886
|
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) +
|
|
767
887
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
768
|
-
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
888
|
+
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>'
|
|
889
|
+
html += parkRow(s)
|
|
769
890
|
html += spacePicker(s, spv, 'session', s.id)
|
|
770
891
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
771
892
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
@@ -1577,6 +1698,8 @@ function settingsDots() {
|
|
|
1577
1698
|
if ((st.knocked || []).length) add('sync', 'warn', 'A Mac asked to connect without the pairing proof')
|
|
1578
1699
|
}
|
|
1579
1700
|
if (st && st.run && st.run.state === 'failed') add('sync', 'warn', 'The last Syncthing step failed')
|
|
1701
|
+
const sy = state.health && state.health.sync
|
|
1702
|
+
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
|
|
1580
1703
|
const peers = syncUi.peers && syncUi.peers.peers
|
|
1581
1704
|
if (peers && peers.some((x) => x.trust === 'key_changed')) add('security', 'warn', 'A machine’s key changed')
|
|
1582
1705
|
const ag = agentUi.data && agentUi.data.agents
|
|
@@ -1730,9 +1853,9 @@ async function viewSettings() {
|
|
|
1730
1853
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
1731
1854
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
1732
1855
|
'<div class="section">Your data</div>' +
|
|
1733
|
-
'<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '"
|
|
1856
|
+
'<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>'),
|
|
1734
1857
|
updates: '<div class="section">About</div>' +
|
|
1735
|
-
'<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="' +
|
|
1858
|
+
'<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>' +
|
|
1736
1859
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
1737
1860
|
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>' +
|
|
1738
1861
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
@@ -1807,8 +1930,10 @@ function syncSection(d, st) {
|
|
|
1807
1930
|
|
|
1808
1931
|
// ------------------------------------------------------------ Settings → Agents (v0.5.4)
|
|
1809
1932
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1933
|
+
// 0.8.1: the repo is private, so About links to the public npm page and the
|
|
1934
|
+
// docs open in the board, served from the installed package (src/docs.js).
|
|
1935
|
+
const NPM_URL = 'https://www.npmjs.com/package/@leftoff/cli'
|
|
1936
|
+
const AGENT_HOSTING_URL = '#/docs/agent-feed-hosting'
|
|
1812
1937
|
const agentUi = { data: null, err: null, busy: false }
|
|
1813
1938
|
function agentStatusLine(a) {
|
|
1814
1939
|
const st = a.status || {}
|
|
@@ -1834,7 +1959,7 @@ function agentsSectionHtml() {
|
|
|
1834
1959
|
const local = d.viewer && d.viewer.local
|
|
1835
1960
|
let h = '<div class="section dup" id="agents">Agents</div>' +
|
|
1836
1961
|
'<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.') +
|
|
1837
|
-
' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '"
|
|
1962
|
+
' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '">How to host a feed</a></div>'
|
|
1838
1963
|
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>'
|
|
1839
1964
|
if (local) {
|
|
1840
1965
|
h += '<div class="full agentadd"><div class="ah"><strong>Add agent feed</strong></div>' +
|
|
@@ -2037,7 +2162,7 @@ async function syncAction(action, btn) {
|
|
|
2037
2162
|
// pair another Mac with a one-time code, join with a code, status, stop. All
|
|
2038
2163
|
// changes are loopback only; a phone sees the status and nothing else.
|
|
2039
2164
|
|
|
2040
|
-
const SECURITY_URL =
|
|
2165
|
+
const SECURITY_URL = '#/docs/security'
|
|
2041
2166
|
const PROTECT = {
|
|
2042
2167
|
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.'],
|
|
2043
2168
|
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.'],
|
|
@@ -2048,7 +2173,7 @@ const PROTECT = {
|
|
|
2048
2173
|
function protectHtml(kind, { open = false } = {}) {
|
|
2049
2174
|
const items = PROTECT[kind] || PROTECT.other
|
|
2050
2175
|
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>' +
|
|
2051
|
-
'<a href="' + SECURITY_URL + '"
|
|
2176
|
+
'<a href="' + SECURITY_URL + '">The full security notes</a></details>'
|
|
2052
2177
|
}
|
|
2053
2178
|
|
|
2054
2179
|
const stUi = { data: null, panel: null, pair: null, joinCode: '', path: '', timer: null, busy: false, error: null, showLog: false }
|
|
@@ -2065,7 +2190,7 @@ function stSection(d) {
|
|
|
2065
2190
|
|
|
2066
2191
|
function stStatusLine(d) {
|
|
2067
2192
|
if (!d.installed) return '<span class="chip">not installed</span>'
|
|
2068
|
-
if (!d.running) return '<span class="chip warn">installed · not running</span>'
|
|
2193
|
+
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>' : '')
|
|
2069
2194
|
const f = d.folder
|
|
2070
2195
|
let h = '<span class="chip ok">' + icon('check') + 'running' + (d.version ? ' · ' + esc(d.version) : '') + '</span>'
|
|
2071
2196
|
if (f) {
|
|
@@ -2080,7 +2205,7 @@ function stPeersHtml(d) {
|
|
|
2080
2205
|
if (!d.folder) return ''
|
|
2081
2206
|
if (!d.peers.length) return '<div class="hint">No other Mac yet.</div>'
|
|
2082
2207
|
return '<div class="stpeers">' + d.peers.map((p) => '<div class="stpeer"><span class="chip machine remote">⌂ ' + esc(p.name) + '</span>' +
|
|
2083
|
-
(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>'
|
|
2208
|
+
(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>'
|
|
2084
2209
|
}
|
|
2085
2210
|
|
|
2086
2211
|
// A Mac that knocked during a pairing without the proof (never auto-accepted).
|
|
@@ -2151,7 +2276,7 @@ function stBoxHtml(d) {
|
|
|
2151
2276
|
let h = '<div class="ststatus">' + stStatusLine(d) + (d.folder ? '<span class="chip mono">' + esc(d.folder.display) + '</span>' : '') + '</div>'
|
|
2152
2277
|
if (d.folder) h += stPeersHtml(d)
|
|
2153
2278
|
h += stKnockedHtml(d, local)
|
|
2154
|
-
|
|
2279
|
+
;((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>' })
|
|
2155
2280
|
if (!local) return h + '<div class="hint">Installing, pairing and joining happen on the Mac itself.</div>'
|
|
2156
2281
|
const run = d.run && Date.now() - new Date(d.run.at).getTime() < 3600000 ? d.run : null
|
|
2157
2282
|
const running = run && !['done', 'failed'].includes(run.state)
|
|
@@ -2443,6 +2568,7 @@ async function render() {
|
|
|
2443
2568
|
else if (r.name === 'lists') await viewLists(r.arg)
|
|
2444
2569
|
else if (r.name === 'settings') await viewSettings()
|
|
2445
2570
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
2571
|
+
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
2446
2572
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
2447
2573
|
else await viewBoard()
|
|
2448
2574
|
} catch (e) {
|
|
@@ -2863,6 +2989,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
2863
2989
|
if (!confirm('Sign out every remote browser, the home-screen app included? Each needs a new QR code or code.')) return
|
|
2864
2990
|
const r = await api('POST', '/v1/remote/revoke'); toast('Signed out ' + r.revoked + ' browser' + (r.revoked === 1 ? '' : 's')); return render()
|
|
2865
2991
|
}
|
|
2992
|
+
if (action === 'dismiss-setup') { try { window.localStorage.setItem(SETUP_KEY, '1') } catch {} return render() }
|
|
2993
|
+
if (action === 'sync-details') { state.syncOpen = !state.syncOpen; return render() }
|
|
2866
2994
|
if (action === 'dismiss-upgrade') { await api('POST', '/v1/changelog/dismiss'); await refreshHealth(); return render() }
|
|
2867
2995
|
if (action === 'agent-add') return agentAdd(btn)
|
|
2868
2996
|
if (action === 'agent-refresh') {
|
|
@@ -2996,7 +3124,12 @@ document.addEventListener('click', async (ev) => {
|
|
|
2996
3124
|
return render()
|
|
2997
3125
|
}
|
|
2998
3126
|
if (action === 'go') return goClick(btn, id, s)
|
|
2999
|
-
if (action === 'resume-open')
|
|
3127
|
+
if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
|
|
3128
|
+
if (action === 'unpark') {
|
|
3129
|
+
btn.disabled = true
|
|
3130
|
+
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 }
|
|
3131
|
+
return render()
|
|
3132
|
+
}
|
|
3000
3133
|
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 }
|
|
3001
3134
|
// 0.7.0: only sessions with docs, plans or artifacts
|
|
3002
3135
|
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 }
|
|
@@ -3241,6 +3374,14 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3241
3374
|
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 }
|
|
3242
3375
|
return
|
|
3243
3376
|
}
|
|
3377
|
+
if (ev.target.dataset && ev.target.dataset.park) {
|
|
3378
|
+
ev.preventDefault()
|
|
3379
|
+
const input = ev.target.querySelector('input[name=note]')
|
|
3380
|
+
const note = input.value.trim()
|
|
3381
|
+
const b = ev.target.querySelector('button'); if (b) { b.disabled = true; b.textContent = 'Parking…' }
|
|
3382
|
+
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' } }
|
|
3383
|
+
return
|
|
3384
|
+
}
|
|
3244
3385
|
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3245
3386
|
ev.preventDefault()
|
|
3246
3387
|
const input = ev.target.querySelector('input[name=ref]')
|
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))
|
package/ui/needs.js
CHANGED
|
@@ -47,7 +47,7 @@ const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal
|
|
|
47
47
|
|
|
48
48
|
export function cardBadge(n) {
|
|
49
49
|
if (!n) return ''
|
|
50
|
-
return '<div class="need-badge k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" title="' + esc(needsLine(n)) + '"><span class="nb-k">' + esc(KIND_LABEL[n.kind] || 'Needs you') + '</span>' +
|
|
50
|
+
return '<div class="need-badge k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" title="' + esc(needsLine(n)) + '"><span class="nb-k">' + esc(n.label || KIND_LABEL[n.kind] || 'Needs you') + '</span>' +
|
|
51
51
|
'<span class="nb-t">' + esc(needsLine(n)) + '</span>' + (n.blocking && n.waitSec != null ? '<span class="nb-w">' + esc(waitLabel(n.waitSec).replace('waiting ', '')) + '</span>' : '') + '</div>'
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -71,7 +71,7 @@ export function needPanel(n, opts = {}) {
|
|
|
71
71
|
h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
|
|
72
72
|
(opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
|
|
73
73
|
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
|
|
74
|
-
'<span class="need-kind">' + esc(KIND_LABEL[n.kind] || '') + '</span>' +
|
|
74
|
+
'<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
|
|
75
75
|
(n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
|
|
76
76
|
h += needContextHtml(n, opts) + prCardHtml(n.pr)
|
|
77
77
|
if (n.kind === 'permission' && n.ask) {
|
package/ui/repos.js
CHANGED
|
@@ -21,11 +21,27 @@ export function repoKeyOf(s) {
|
|
|
21
21
|
// Short labels: the folder name, and for folder names that collide, as many
|
|
22
22
|
// parent folders as it takes to tell them apart ("colorGenie/expo" vs
|
|
23
23
|
// "food_fight/expo"). keys -> Map(key -> label).
|
|
24
|
-
|
|
24
|
+
// 0.8.3: `names` (Map key -> name) is what a repo is called when that's not
|
|
25
|
+
// its folder (its origin remote's name); two repos with one name fall back to
|
|
26
|
+
// their folders. "/" and a bare home folder are "No repo", never "/" or an
|
|
27
|
+
// account name.
|
|
28
|
+
export const NO_REPO_LABEL = 'No repo'
|
|
29
|
+
export function isNoRepoDir(p, home = null) {
|
|
30
|
+
const s = String(p || '').replace(/\/+$/, '')
|
|
31
|
+
if (!s || s === '~' || p === '/' || /^[A-Za-z]:\\?$/.test(s)) return true
|
|
32
|
+
if (home && s === String(home).replace(/\/+$/, '')) return true
|
|
33
|
+
return /^\/(Users|home)\/[^/]+$/.test(s) || s === '/root' || s === '/var/root'
|
|
34
|
+
}
|
|
35
|
+
export function labelRepos(keys, { names = null } = {}) {
|
|
25
36
|
const uniq = [...new Set([...keys].filter(Boolean))]
|
|
26
37
|
const depth = new Map(uniq.map((k) => [k, 1]))
|
|
38
|
+
const nameOf = (k) => (names && names.get(k)) || null
|
|
39
|
+
const named = new Map()
|
|
40
|
+
for (const k of uniq) { const n = nameOf(k); if (n) named.set(n, (named.get(n) || 0) + 1) }
|
|
27
41
|
const label = (k) => {
|
|
28
|
-
if (k === 'no-repo') return
|
|
42
|
+
if (k === 'no-repo' || isNoRepoDir(k)) return NO_REPO_LABEL
|
|
43
|
+
const n = nameOf(k)
|
|
44
|
+
if (n && named.get(n) === 1 && depth.get(k) === 1 && !uniq.some((o) => o !== k && !nameOf(o) && baseOf(o) === n)) return n
|
|
29
45
|
const s = segments(k)
|
|
30
46
|
return s.length ? s.slice(-Math.min(depth.get(k), s.length)).join('/') : k
|
|
31
47
|
}
|
|
@@ -168,6 +184,12 @@ export function inStatus(s, status) {
|
|
|
168
184
|
|
|
169
185
|
// Which chips show in the row and which go behind "More ▾": the top `max` by
|
|
170
186
|
// recent activity, plus any selected one that would otherwise be hidden.
|
|
187
|
+
// 0.8.3: chosen chips first, in the order chosen, then the rest as they were.
|
|
188
|
+
export function orderSelectedFirst(chips, selected = []) {
|
|
189
|
+
if (!selected || !selected.length) return chips
|
|
190
|
+
const sel = chips.filter((c) => chipSelected(selected, c))
|
|
191
|
+
return [...sel, ...chips.filter((c) => !sel.includes(c))]
|
|
192
|
+
}
|
|
171
193
|
export function splitChips(chips, selected = [], max = 8) {
|
|
172
194
|
if (chips.length <= max) return { shown: chips, more: [] }
|
|
173
195
|
const isSel = (c) => chipSelected(selected, c)
|
package/ui/viewer.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 0.8.3: who's looking at the board. The daemon says whether the request came
|
|
2
|
+
// from another computer (a tailnet or LAN name, the relay); the browser tells
|
|
3
|
+
// a phone or tablet from a desktop by its user agent. Plain functions, no DOM.
|
|
4
|
+
|
|
5
|
+
// Phones and tablets (an iPad asking for the desktop site says "Macintosh"
|
|
6
|
+
// but has a touch screen: maxTouchPoints > 1).
|
|
7
|
+
export function isPhoneUa(ua, { maxTouchPoints = 0 } = {}) {
|
|
8
|
+
const u = String(ua || '')
|
|
9
|
+
if (/iPhone|iPod|Android.*Mobile|Mobile Safari|Windows Phone|BlackBerry|Opera Mini|IEMobile/i.test(u)) return true
|
|
10
|
+
if (/iPad|Android(?!.*Mobile)|Tablet|Silk/i.test(u)) return true
|
|
11
|
+
if (/Macintosh/.test(u) && maxTouchPoints > 1) return true
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "Set up this Mac too": only a desktop browser on another computer, until dismissed.
|
|
16
|
+
export function wantsSetupBanner({ viewer, ua, maxTouchPoints = 0, dismissed = false } = {}) {
|
|
17
|
+
if (!viewer || !viewer.remote || !viewer.setup || dismissed) return false
|
|
18
|
+
return !isPhoneUa(ua, { maxTouchPoints })
|
|
19
|
+
}
|