@leftoff/cli 0.9.18-beta.9 → 0.9.18
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 +25 -2
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +792 -677
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +259 -17
- package/mod/hooks/register.js +18 -4
- package/package.json +1 -1
- package/ui/app.css +204 -5
- package/ui/app.js +621 -189
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +42 -0
- package/ui/decisions.js +81 -21
- package/ui/digest-versions.js +57 -0
- package/ui/fs-side.js +29 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +136 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +44 -3
- package/ui/live-view.js +36 -5
- package/ui/live-wake.js +47 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/prs.js +28 -10
- package/ui/pull.js +30 -3
- package/ui/relay-client.js +2 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +58 -27
- package/ui/session-title.js +88 -0
- package/ui/settings-nav.js +1 -1
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/subagents-view.js +41 -0
- package/ui/summary-examples.js +1 -1
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +29 -3
- package/ui/walk-spots.js +35 -1
package/ui/chip-fade.js
CHANGED
|
@@ -18,7 +18,7 @@ export function paintFade(track) {
|
|
|
18
18
|
return s
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const SEL = '.repos .track'
|
|
21
|
+
const SEL = '.repos .track, #tabStrip .tab-scroll' // 0.9.18: the tab strip fades where tabs run off too
|
|
22
22
|
// Keeps every chip row's fade right: on first paint (any re-render adds new
|
|
23
23
|
// tracks), on its own scroll, and when its width or its chips' widths change.
|
|
24
24
|
export function watchChipFades(doc = document) {
|
package/ui/cp-fold.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A checkpoint's folded extras, named for what's inside (2026-11-05). The old fixed summary said
|
|
2
|
+
// "Decisions, files, open questions" although that section has been Approach notes since
|
|
3
|
+
// 2026-11-03 (real decisions live under Decided), which made decision memory look broken.
|
|
4
|
+
// Unit-tested in test/unit/cp-fold-1105.test.mjs.
|
|
5
|
+
|
|
6
|
+
// In the order the summary names them. 'decided' counts only where the Decided lines are shown.
|
|
7
|
+
const FOLD_NAMES = [['decided', 'decisions'], ['recap', 'recap'], ['decisions', 'approach notes'], ['files', 'files'], ['questions', 'open questions'], ['didntWork', 'what didn’t work'], ['nextSteps', 'next steps']]
|
|
8
|
+
|
|
9
|
+
const present = (cp, k, decided) => {
|
|
10
|
+
if (!cp) return false
|
|
11
|
+
if (k === 'decided') return !!decided && Array.isArray(cp.decided) && cp.decided.length > 0
|
|
12
|
+
const v = cp[k]
|
|
13
|
+
return Array.isArray(v) ? v.length > 0 : !!v
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// "Approach notes, files, open questions": the first three sections present, sentence case. '' when none.
|
|
17
|
+
export function cpFoldSummary(cp, { decided = false } = {}) {
|
|
18
|
+
const names = FOLD_NAMES.filter(([k]) => present(cp, k, decided)).map(([, n]) => n).slice(0, 3)
|
|
19
|
+
if (!names.length) return ''
|
|
20
|
+
const t = names.join(', ')
|
|
21
|
+
return t.charAt(0).toUpperCase() + t.slice(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2026-11-06: a checkpoint's "Got done" facts (cp.work: commits, PRs, pushes, the last test run, from the
|
|
25
|
+
// commands' own output) as short lines for its card: [{ kind, text }], at most max, then "+N more".
|
|
26
|
+
// kind is a class name (merged, opened, commit, push, pass, fail); the words say the same, shape and text.
|
|
27
|
+
export function workItems(cp, { max = 5 } = {}) {
|
|
28
|
+
const w = cp && Array.isArray(cp.work) ? cp.work : []
|
|
29
|
+
const out = []
|
|
30
|
+
for (const f of w) {
|
|
31
|
+
if (!f || typeof f !== 'object') continue
|
|
32
|
+
if (f.kind === 'pr' && f.number) out.push({ kind: f.state === 'merged' ? 'merged' : 'opened', text: (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.title ? ': ' + String(f.title) : '') + (f.state === 'opened' ? ' (not merged yet)' : '') })
|
|
33
|
+
else if (f.kind === 'commit' && f.subject) out.push({ kind: 'commit', text: 'Committed ' + (f.sha ? '`' + String(f.sha).slice(0, 7) + '` ' : '') + String(f.subject) })
|
|
34
|
+
else if (f.kind === 'push' && f.branch) out.push({ kind: 'push', text: 'Pushed ' + String(f.branch) })
|
|
35
|
+
else if (f.kind === 'tests') out.push({ kind: f.ok ? 'pass' : 'fail', text: (f.ok ? 'Tests passed' : 'Tests failing') + (f.passed != null ? ': ' + (f.ok ? f.passed + ' passed' : f.failed + ' failed, ' + f.passed + ' passed') : '') + (f.cmd ? ' (`' + String(f.cmd).replace(/`/g, '') + '`)' : '') })
|
|
36
|
+
}
|
|
37
|
+
// the PRs and tests matter most; commits fill what's left
|
|
38
|
+
const top = out.filter((x) => x.kind !== 'commit' && x.kind !== 'push'), rest = out.filter((x) => x.kind === 'commit' || x.kind === 'push')
|
|
39
|
+
const all = [...top, ...rest]
|
|
40
|
+
if (all.length <= max) return all
|
|
41
|
+
return [...all.slice(0, max - 1), { kind: 'more', text: '+' + (all.length - max + 1) + ' more' }]
|
|
42
|
+
}
|
package/ui/decisions.js
CHANGED
|
@@ -10,29 +10,79 @@ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', corr
|
|
|
10
10
|
export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
|
|
11
11
|
export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
|
|
13
|
+
// 2026-11-05: a status Leftoff set on its own (statusAuto), in words beside the source, and why in the tip.
|
|
14
|
+
export const AUTO_WORDS = { asked: 'automatically', plan: 'automatically', reversed: 'you changed it later' }
|
|
15
|
+
export const AUTO_TIPS = {
|
|
16
|
+
asked: 'Accepted on its own: you picked this answer in the session. Reject keeps it out of new sessions.',
|
|
17
|
+
plan: 'Accepted on its own: you approved this plan in the session. Reject keeps it out of new sessions.',
|
|
18
|
+
reversed: 'Rejected on its own: later in the session you answered the same question differently.',
|
|
19
|
+
}
|
|
20
|
+
const autoOf = (d) => (d && ((d.status === 'accepted' && (d.statusAuto === 'asked' || d.statusAuto === 'plan')) || (d.status === 'rejected' && d.statusAuto === 'reversed')) ? d.statusAuto : null)
|
|
21
|
+
const autoBit = (d, esc) => { const a = autoOf(d); return a ? '<span class="dec-auto" data-tip="' + esc(AUTO_TIPS[a]) + '">' + esc(AUTO_WORDS[a]) + '</span>' : '' }
|
|
22
|
+
const normD = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
23
|
+
const byNewest = (a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
|
|
24
|
+
|
|
25
|
+
// Every decision a session made (2026-11-05), newest first. Each later checkpoint leaves out what an earlier one
|
|
26
|
+
// recorded, so the newest checkpoint alone loses them. entries: the session's rows from its repo's log (GET
|
|
27
|
+
// /v1/decisions, sessionId === this session), or null until the log loads / with no repo. A checkpoint's line the
|
|
28
|
+
// log doesn't have yet (written on another Mac, not folded in) still shows, without a status.
|
|
29
|
+
// -> [{ text, why, alternatives, source, entry }]
|
|
30
|
+
export function sessionDecisions(checkpoints, entries = null) {
|
|
31
|
+
const out = [], seen = new Set()
|
|
32
|
+
const log = Array.isArray(entries) ? [...entries].sort(byNewest) : []
|
|
33
|
+
for (const d of log) {
|
|
34
|
+
out.push({ text: d.text, why: d.why || '', alternatives: d.alternatives || [], source: d.source, entry: d })
|
|
35
|
+
seen.add(normD(d.text)); if (d.original && d.original.text) seen.add(normD(d.original.text))
|
|
36
|
+
}
|
|
37
|
+
const cps = (Array.isArray(checkpoints) ? checkpoints : []).filter((c) => c && !c.doubt).sort(byNewest)
|
|
38
|
+
for (const cp of cps) for (const d of Array.isArray(cp.decided) ? cp.decided : []) {
|
|
39
|
+
const k = normD(d && d.text)
|
|
40
|
+
if (!k || seen.has(k)) continue
|
|
41
|
+
seen.add(k); out.push({ text: d.text, why: d.why || '', alternatives: d.alternatives || [], source: d.source, entry: null })
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
// The fold's count: the total, and how many wait on you.
|
|
46
|
+
export function sessionDecisionsCount(list) {
|
|
47
|
+
const n = (list || []).length, rev = (list || []).filter((d) => d.entry && d.entry.status === 'proposed').length
|
|
48
|
+
return n + (rev ? ' · ' + rev + ' to review' : '')
|
|
49
|
+
}
|
|
50
|
+
// The log's rows for one session, as the decided list reads them ({ <text norm>: row }, the original wording too).
|
|
51
|
+
export function logBySession(decisions, sessionId) {
|
|
52
|
+
const by = {}
|
|
53
|
+
for (const d of decisions || []) if (d && d.sessionId === sessionId) { by[normD(d.text)] = d; if (d.original && d.original.text) by[normD(d.original.text)] = d }
|
|
54
|
+
return by
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A session's decisions, in the session panel's Decided fold.
|
|
58
|
+
// decided: sessionDecisions() rows ({ entry }), or a checkpoint's plain decided list read against log
|
|
59
|
+
// ({ <text norm>: { id, status } }, when loaded).
|
|
15
60
|
// 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
|
|
16
61
|
// device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
|
|
17
|
-
|
|
62
|
+
// readOnly: an older checkpoint's lines in the Timeline (status only, no buttons, no link).
|
|
63
|
+
export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true, readOnly = false } = {}) {
|
|
18
64
|
const list = Array.isArray(decided) ? decided : []
|
|
19
65
|
if (!list.length) return ''
|
|
20
|
-
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
21
66
|
const rows = list.map((d) => {
|
|
22
|
-
const hit = log && log[
|
|
67
|
+
const hit = d.entry || (log && log[normD(d.text)]) || null
|
|
23
68
|
const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[hit.status] + '</span> ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
|
|
24
|
-
if (hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
|
|
69
|
+
if (!readOnly && hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
|
|
25
70
|
const rp = (hit && (hit.repo || repoId)) || ''
|
|
26
71
|
const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
|
|
27
|
-
const acts = hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
|
|
28
|
-
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
const acts = !readOnly && hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
|
|
73
|
+
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
|
|
74
|
+
: !readOnly && hit && may && autoOf(hit) && hit.status === 'accepted' ? '<div class="dec-acts dec-acts-panel dec-acts-quiet">' + btn('reject', 'Reject', '') + '</div>' : ''
|
|
75
|
+
const why = (hit && hit.why) || d.why, alts = (hit && hit.alternatives && hit.alternatives.length ? hit.alternatives : d.alternatives) || []
|
|
76
|
+
// a quiet line first (status · id · where it came from), then the decision in words, then why / over
|
|
77
|
+
const src = '<span class="dec-src">' + esc(SOURCE_WORDS[(hit && hit.source) || d.source] || SOURCE_WORDS.agent) + '</span>'
|
|
78
|
+
const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src, autoBit(hit, esc)].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
|
|
79
|
+
return '<li class="dec-row' + (hit ? ' dec-row-' + esc(hit.status) : '') + '"' + (hit && !readOnly ? ' id="decp-' + esc(hit.id) + '"' : '') + '><div class="dec-row-h">' + head + '</div>' +
|
|
80
|
+
'<div class="dec-text">' + esc(hit && (hit.edited || d.entry) ? hit.text : d.text) + '</div>' +
|
|
81
|
+
(why ? '<div class="dec-why">' + esc(why) + '</div>' : '') +
|
|
82
|
+
(alts.length ? '<div class="dec-alt">Over: ' + alts.map(esc).join(' · ') + '</div>' : '') + acts + '</li>'
|
|
33
83
|
}).join('')
|
|
34
|
-
const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
35
|
-
return '<ul class="dec-list">' + rows + '</ul>' + link
|
|
84
|
+
const link = repoId && !readOnly ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
85
|
+
return '<ul class="dec-list' + (readOnly ? ' dec-list-ro' : '') + '">' + rows + '</ul>' + link
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
// 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
|
|
@@ -115,13 +165,13 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
|
|
|
115
165
|
const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
|
|
116
166
|
const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
|
|
117
167
|
const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
|
|
118
|
-
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
|
|
168
|
+
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : (autoOf(d) && d.status === 'accepted' ? btn('reject', 'Reject') : '') + editBtn('linkish')) + link + '</div>'
|
|
119
169
|
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
120
170
|
'<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
|
|
121
171
|
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
122
172
|
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
123
173
|
chainBit(d, byId, esc) + sug +
|
|
124
|
-
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
174
|
+
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (autoBit(d, esc) ? ' · ' + autoBit(d, esc) : '') + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
125
175
|
}
|
|
126
176
|
|
|
127
177
|
// The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
|
|
@@ -135,6 +185,12 @@ export function storageChoiceHtml(project, { esc = escD, may = true, trusted = t
|
|
|
135
185
|
: 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
|
|
136
186
|
}
|
|
137
187
|
|
|
188
|
+
// 2026-11-05 an empty log says where decisions come from, so nobody has to guess whether it's working
|
|
189
|
+
export const DECISIONS_EMPTY = ['Decisions come from checkpoints. A question Claude asked that you answered, a plan you approved, or a correction you gave becomes a proposed decision here at the next checkpoint.', 'Checkpoints written before decision memory aren’t read for it.']
|
|
190
|
+
export function decisionsEmptyHtml({ esc = escD } = {}) {
|
|
191
|
+
return '<div class="dec-empty">' + DECISIONS_EMPTY.map((t) => '<p class="faint">' + esc(t) + '</p>').join('') + '</div>'
|
|
192
|
+
}
|
|
193
|
+
|
|
138
194
|
// The whole page. v: GET /v1/decisions. ui: { all, editing, may }
|
|
139
195
|
export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
140
196
|
const list = (v && v.decisions) || []
|
|
@@ -149,11 +205,12 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
|
149
205
|
(multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
|
|
150
206
|
if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
|
|
151
207
|
if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
|
|
152
|
-
if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
|
|
208
|
+
if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Questions you answered and plans you approved are accepted on their own. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
|
|
153
209
|
const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
|
|
154
|
-
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' +
|
|
210
|
+
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' +
|
|
211
|
+
'<button type="button" class="btn save-html" data-action="dec-save-html" data-tip="Save as HTML · one file with nothing linked out: opens offline, on a phone, and prints">Save as HTML</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>' // 2026-11-06
|
|
155
212
|
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
156
|
-
(rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">
|
|
213
|
+
(rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : list.length ? '<p class="faint">Nothing accepted yet.</p>' : decisionsEmptyHtml({ esc })) + '</section>'
|
|
157
214
|
return '<div class="proj dec-page">' + h + '</div>'
|
|
158
215
|
}
|
|
159
216
|
|
|
@@ -175,12 +232,15 @@ export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
|
|
|
175
232
|
}
|
|
176
233
|
|
|
177
234
|
// The project page's short Decisions section: counts, the storage choice, the way in.
|
|
235
|
+
// 2026-11-05 a single repo's page (v.scope.kind 'repo') gets it too, without the storage choice: that stays a project's.
|
|
178
236
|
export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
|
|
179
237
|
if (!v) return ''
|
|
238
|
+
const isRepo = !!(v.scope && v.scope.kind === 'repo')
|
|
180
239
|
const list = v.decisions || []
|
|
181
240
|
const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
|
|
182
241
|
return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
|
|
183
|
-
'<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
|
|
242
|
+
'<p>' + (!list.length ? 'None yet' : cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
|
|
243
|
+
(list.length ? '' : decisionsEmptyHtml({ esc })) +
|
|
184
244
|
'<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
|
|
185
|
-
storageChoiceHtml(v.project, { esc, may, trusted }) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
|
|
245
|
+
(isRepo ? '<p class="faint dec-local">' + (v.project ? 'Where its log is kept is set on ' + esc(v.project.name) + '’s page.' : 'Keeping the log in the repo too is a project choice: available once this repo is in a project.') + '</p>' : storageChoiceHtml(v.project, { esc, may, trusted })) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
|
|
186
246
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The digest's version note (2026-11-06, src/history.js): a quiet "v3 · changed since 9:00 AM" under the
|
|
2
|
+
// title when a day's digest was made more than once, and a list to open an older version
|
|
3
|
+
// (#/digest/<date>?v=N). "Since" is the version this device last opened, kept in localStorage; with none
|
|
4
|
+
// (or the newest), it just says how many versions there are. An older version says so in words, with a
|
|
5
|
+
// way back. Pure functions plus one paint; the board's renderer is not touched.
|
|
6
|
+
|
|
7
|
+
export const SEEN_KEY = 'lo.digestSeen'
|
|
8
|
+
const hm = (iso) => { const t = new Date(iso); return Number.isNaN(t.getTime()) ? '' : t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) }
|
|
9
|
+
|
|
10
|
+
// { date: version } this device opened
|
|
11
|
+
export function readSeen(storage) { try { const o = JSON.parse(storage.getItem(SEEN_KEY) || '{}'); return o && typeof o === 'object' ? o : {} } catch { return {} } }
|
|
12
|
+
export function markSeen(storage, date, version) {
|
|
13
|
+
try {
|
|
14
|
+
const o = readSeen(storage)
|
|
15
|
+
if ((o[date] || 0) >= version) return
|
|
16
|
+
o[date] = version
|
|
17
|
+
const keep = Object.keys(o).sort().slice(-30) // a month of days is plenty
|
|
18
|
+
storage.setItem(SEEN_KEY, JSON.stringify(Object.fromEntries(keep.map((k) => [k, o[k]]))))
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// d: the GET /v1/digests/<date> answer; seen: the version this device last opened (or null)
|
|
23
|
+
// -> { label, older, latest, items: [{ version, label, href, current, shown }] } | null (one version: nothing to show)
|
|
24
|
+
export function versionNote(d, date, seen = null) {
|
|
25
|
+
const vs = d && d.versions
|
|
26
|
+
if (!vs || !Array.isArray(vs.versions) || vs.count < 2) return null
|
|
27
|
+
const shown = d.version || vs.latest
|
|
28
|
+
const latest = vs.versions.find((v) => v.version === vs.latest) || vs.versions[vs.versions.length - 1]
|
|
29
|
+
const since = seen && seen < vs.latest ? vs.versions.find((v) => v.version === seen) : null
|
|
30
|
+
const label = shown !== vs.latest ? 'v' + shown + ' of ' + vs.count : since ? 'v' + vs.latest + ' · changed since ' + hm(since.generatedAt) : 'v' + vs.latest + ' · ' + vs.count + ' versions'
|
|
31
|
+
const items = vs.versions.slice().reverse().map((v) => ({ version: v.version, label: 'v' + v.version + ' · ' + hm(v.generatedAt) + (v.version === vs.latest ? ' (latest)' : ''), href: '#/digest/' + date + (v.version === vs.latest ? '' : '?v=' + v.version), current: v.version === vs.latest, shown: v.version === shown }))
|
|
32
|
+
return { label, older: shown !== vs.latest, shown, latest: vs.latest, latestAt: latest.generatedAt, shownAt: (vs.versions.find((v) => v.version === shown) || {}).generatedAt, items }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function versionNoteHtml(n, { esc, date }) {
|
|
36
|
+
if (!n) return null
|
|
37
|
+
// a div, not a ul: the digest's repo filter shows and hides every ul in the article
|
|
38
|
+
const menu = '<div class="dv-list" id="dvList" role="list" hidden>' + n.items.map((i) => '<a role="listitem" href="' + esc(i.href) + '"' + (i.shown ? ' aria-current="page"' : '') + '>' + (i.shown ? '● ' : '○ ') + esc(i.label) + '</a>').join('') + '</div>'
|
|
39
|
+
const btn = '<button type="button" class="dv-btn" aria-expanded="false" aria-controls="dvList" data-tip="Earlier versions of this digest">' + esc(n.label) + ' ▾</button>'
|
|
40
|
+
const old = n.older ? '<div class="banner dv-old" role="status"><span class="grow"><b>◷ An earlier version</b>: v' + n.shown + ', made ' + esc(hm(n.shownAt)) + '. The latest is v' + n.latest + ' (' + esc(hm(n.latestAt)) + ').</span><a class="btn" href="#/digest/' + esc(date) + '">Back to latest</a></div>' : ''
|
|
41
|
+
return { old, note: '<span class="dv">' + btn + menu + '</span>' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// After the digest is on the page: the note on its own line under the meta line, the banner above the article.
|
|
45
|
+
export function paintDigestVersion(root, d, date, { esc, storage }) {
|
|
46
|
+
const seen = storage ? readSeen(storage)[date] || null : null
|
|
47
|
+
const n = versionNote(d, date, seen)
|
|
48
|
+
if (storage && d && d.version && d.versions && d.version === d.versions.latest) markSeen(storage, date, d.version)
|
|
49
|
+
if (!n) return null
|
|
50
|
+
const html = versionNoteHtml(n, { esc, date })
|
|
51
|
+
const meta = root.querySelector('article.md .digest-meta')
|
|
52
|
+
if (meta) meta.insertAdjacentHTML('afterend', '<div class="dv-line">' + html.note + '</div>')
|
|
53
|
+
if (html.old) { const art = root.querySelector('article.md'); if (art) art.insertAdjacentHTML('beforebegin', html.old) }
|
|
54
|
+
const btn = root.querySelector('.dv-btn'), list = root.querySelector('#dvList')
|
|
55
|
+
if (btn && list) btn.addEventListener('click', () => { const open = btn.getAttribute('aria-expanded') !== 'true'; btn.setAttribute('aria-expanded', String(open)); list.hidden = !open })
|
|
56
|
+
return n
|
|
57
|
+
}
|
package/ui/fs-side.js
CHANGED
|
@@ -80,3 +80,32 @@ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cp
|
|
|
80
80
|
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
81
81
|
resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
// 2026-11-06 The phone's sheet follows a finger: drag its peek up to raise it, and drag the raised
|
|
85
|
+
// sheet down to lower it, from the peek or from its content when that's scrolled to the very top.
|
|
86
|
+
// Everywhere else a finger scrolls the sheet's content natively, both ways (a drag never starts
|
|
87
|
+
// once the content has room above it, nor when the finger first heads the way that scrolls it).
|
|
88
|
+
export const SHEET_DRAG_SLOP = 8 // px a finger moves before it's a drag or a scroll
|
|
89
|
+
export const SHEET_DRAG_FLIP = 64 // px of drag that raises or lowers the sheet on letting go
|
|
90
|
+
// up: the sheet is raised; fromPeek: the finger started on the peek; scrollTop: the content's at the touch's start;
|
|
91
|
+
// dy, dx: the finger's travel so far (down and right positive) -> 'drag' | 'scroll' | null (not decided yet)
|
|
92
|
+
export function sheetDragStart({ up = false, fromPeek = false, scrollTop = 0, dy = 0, dx = 0 } = {}) {
|
|
93
|
+
const ay = Math.abs(dy), ax = Math.abs(dx)
|
|
94
|
+
if (ay < SHEET_DRAG_SLOP && ax < SHEET_DRAG_SLOP) return null
|
|
95
|
+
if (ax > ay) return 'scroll' // sideways: a table or code block, never the sheet
|
|
96
|
+
if (!up) return fromPeek && dy < 0 ? 'drag' : 'scroll' // lowered: only the peek, only upwards
|
|
97
|
+
if (dy <= 0) return 'scroll' // raised, finger heading up: the content scrolls down
|
|
98
|
+
return fromPeek || (Number(scrollTop) || 0) <= 0 ? 'drag' : 'scroll'
|
|
99
|
+
}
|
|
100
|
+
// How far the sheet sits from where it rests while dragged (px, down positive; never past either end).
|
|
101
|
+
// travel: the px between raised and lowered.
|
|
102
|
+
export function sheetDragOffset({ up = false, dy = 0, travel = 0 } = {}) {
|
|
103
|
+
const t = Math.max(0, Number(travel) || 0)
|
|
104
|
+
return up ? Math.round(Math.max(0, Math.min(t, dy))) : Math.round(Math.max(-t, Math.min(0, dy)))
|
|
105
|
+
}
|
|
106
|
+
// Letting go: true when the sheet should change (raise or lower), from the travel or a quick flick.
|
|
107
|
+
export function sheetDragFlips({ up = false, dy = 0, ms = 1000 } = {}) {
|
|
108
|
+
const d = up ? dy : -dy
|
|
109
|
+
if (d >= SHEET_DRAG_FLIP) return true
|
|
110
|
+
return d >= 24 && ms > 0 && d / ms > 0.5 // a flick: 0.5 px/ms
|
|
111
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<!-- The HTML file viewer's sandbox (ui/html-preview.js). Served with its own CSP
|
|
4
|
+
(sandbox allow-scripts, inline only unless ?remote=1). It takes one page from
|
|
5
|
+
the board by postMessage and becomes that page. -->
|
|
6
|
+
<script>
|
|
7
|
+
addEventListener('message', function (e) {
|
|
8
|
+
if (e.source !== parent || !e.data || e.data.t !== 'lo-hp-html') return
|
|
9
|
+
document.open(); document.write(String(e.data.html)); document.close()
|
|
10
|
+
})
|
|
11
|
+
parent.postMessage({ t: 'lo-hp-ready' }, '*')
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 2026-11-06 An HTML file in the viewer renders as a page (Preview · Code).
|
|
2
|
+
// The page never runs in the board: it runs in ui/html-preview.html, a shell
|
|
3
|
+
// served with its own CSP (previewCsp) whose `sandbox` gives it an opaque
|
|
4
|
+
// origin, so it can't read the board's storage or cookies, and its calls to
|
|
5
|
+
// the daemon carry Origin: null (refused). The board hands it the file by
|
|
6
|
+
// postMessage. Remote resources (fonts, CDN scripts, images) are blocked until
|
|
7
|
+
// you press Allow, for that view only; the bar lists what the file asks for.
|
|
8
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
|
|
10
|
+
export const PREVIEW_PAGE = '/html-preview.html'
|
|
11
|
+
export const VIEW_KEY = 'lo-html-view-v1'
|
|
12
|
+
|
|
13
|
+
// The shell's CSP. Inline only by default; `remote` adds https: alone (not
|
|
14
|
+
// http:, which would match a local board's own http://127.0.0.1 and pull its
|
|
15
|
+
// files in for the page's ./style.css). Never 'self'. Copied in
|
|
16
|
+
// relay/src/user.js (a unit test keeps them the same).
|
|
17
|
+
export function previewCsp(remote = false) {
|
|
18
|
+
const r = remote ? ' https:' : ''
|
|
19
|
+
return "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'" + r + "; style-src 'unsafe-inline'" + r +
|
|
20
|
+
'; img-src data: blob:' + r + '; font-src data:' + r + '; media-src data: blob:' + r + '; connect-src ' + (remote ? 'https:' : "'none'") +
|
|
21
|
+
"; frame-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// An HTML file, from either reader: the session's files (type html) or a path
|
|
25
|
+
// in a message (type code, lang html).
|
|
26
|
+
export function isHtmlDoc(r) {
|
|
27
|
+
if (!r || r.missing || r.tooBig || r.binary || typeof r.text !== 'string') return false
|
|
28
|
+
return r.type === 'html' || (r.type === 'code' && (r.lang === 'html' || /^text\/html\b/.test(r.mime || '')))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// What the file loads by itself (not links you'd click): src, srcset, poster,
|
|
32
|
+
// <link href> (not a resource hint), <object data>, CSS url() and @import.
|
|
33
|
+
// Remote ones are blocked until Allow (scripts can fetch more once allowed; the
|
|
34
|
+
// bar says so). Local ones (./style.css, img/x.png, /app.js) never load: the
|
|
35
|
+
// preview has only the file itself, so the bar names them.
|
|
36
|
+
const HINT_RELS = /\b(preconnect|dns-prefetch|prerender)\b/i
|
|
37
|
+
const LINK_TAGS = /^(a|area|base|form)$/i
|
|
38
|
+
const unquote = (u) => String(u || '').trim().replace(/^['"]|['"]$/g, '')
|
|
39
|
+
function remoteUrl(u) {
|
|
40
|
+
const s = unquote(u)
|
|
41
|
+
if (/^https?:\/\//i.test(s)) return s
|
|
42
|
+
if (/^\/\/[^/]/.test(s)) return 'https:' + s
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
function localUrl(u) {
|
|
46
|
+
const s = unquote(u)
|
|
47
|
+
if (!s || remoteUrl(s) || s.charAt(0) === '#' || /^[a-z][a-z\d+.-]*:/i.test(s)) return '' // data:, blob:, about:, mailto:, any other scheme
|
|
48
|
+
return s
|
|
49
|
+
}
|
|
50
|
+
function cssUrls(css, add) {
|
|
51
|
+
for (const m of String(css).matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) add(m[2], 'style')
|
|
52
|
+
for (const m of String(css).matchAll(/@import\s+(['"])([^'"]+)\1/gi)) add(m[2], 'style')
|
|
53
|
+
}
|
|
54
|
+
function scanRefs(html, add) {
|
|
55
|
+
const src = String(html || '').replace(/<!--[\s\S]*?-->/g, '')
|
|
56
|
+
for (const m of src.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
|
|
57
|
+
const tag = m[1].toLowerCase(), attrs = m[2]
|
|
58
|
+
const attr = (n) => { const a = attrs.match(new RegExp('(?:^|\\s)' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')); return a ? (a[1] ?? a[2] ?? a[3]) : null }
|
|
59
|
+
if (LINK_TAGS.test(tag)) continue
|
|
60
|
+
const kind = tag === 'script' ? 'script' : tag === 'link' ? (/stylesheet/i.test(attr('rel') || '') ? 'style' : 'link') : tag === 'img' || tag === 'picture' || tag === 'source' ? 'image' : tag
|
|
61
|
+
if (attr('src')) add(attr('src'), kind)
|
|
62
|
+
if (attr('poster')) add(attr('poster'), 'image')
|
|
63
|
+
if (tag === 'object' && attr('data')) add(attr('data'), 'object')
|
|
64
|
+
if (tag === 'link' && attr('href') && !HINT_RELS.test(attr('rel') || '')) add(attr('href'), kind)
|
|
65
|
+
for (const c of String(attr('srcset') || '').matchAll(/(\S+)(?:\s+[^,]*)?(?:,|$)/g)) add(c[1].replace(/,+$/, ''), 'image') // a data: URL keeps its comma
|
|
66
|
+
if (attr('style')) cssUrls(attr('style'), add)
|
|
67
|
+
}
|
|
68
|
+
for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) cssUrls(m[1], add)
|
|
69
|
+
}
|
|
70
|
+
export function remoteRefs(html) {
|
|
71
|
+
const seen = new Map()
|
|
72
|
+
scanRefs(html, (u, kind) => { const url = remoteUrl(u); if (url && !seen.has(url)) { let host = ''; try { host = new URL(url).host } catch { return } seen.set(url, { url, host, kind }) } })
|
|
73
|
+
return [...seen.values()]
|
|
74
|
+
}
|
|
75
|
+
export function localRefs(html) {
|
|
76
|
+
const seen = new Map()
|
|
77
|
+
scanRefs(html, (u, kind) => { const url = localUrl(u); if (url && !seen.has(url)) seen.set(url, { url, kind }) })
|
|
78
|
+
return [...seen.values()]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The hosts, in first-seen order: "fonts.googleapis.com, fonts.gstatic.com".
|
|
82
|
+
export function refHosts(refs) { return [...new Set((refs || []).map((x) => x.host))] }
|
|
83
|
+
|
|
84
|
+
// What the shell writes: the file, plus a first script that keeps a link click
|
|
85
|
+
// inside the preview from leaving it (it says where the link goes instead),
|
|
86
|
+
// passes Esc on, answers the board's ping, and says when the page is leaving.
|
|
87
|
+
// The preview's own CSP can't stop a page sending its frame somewhere (a script
|
|
88
|
+
// setting location); the board's CSP does (frame-src 'self' covers a child's
|
|
89
|
+
// navigations, so another site is refused before any request). Behind that, the
|
|
90
|
+
// board stops a preview that leaves for anywhere it can go (ping unanswered after
|
|
91
|
+
// a load, or pagehide) and says so. A meta refresh, which
|
|
92
|
+
// leaves with no script, is taken out; while blocked, so are resource hints (a
|
|
93
|
+
// preconnect isn't covered by CSP).
|
|
94
|
+
const GUARD = '<script>(function(){var P=parent,D=document;D.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest("a[href],area[href]");if(!a)return;var h=a.getAttribute("href")||"";if(h.charAt(0)==="#")return;e.preventDefault();P.postMessage({t:"lo-hp-link",href:h},"*")},true);D.addEventListener("keydown",function(e){if(e.key==="Escape")P.postMessage({t:"lo-hp-esc"},"*")});addEventListener("message",function(e){if(e.source===P&&e.data&&e.data.t==="lo-hp-ping")P.postMessage({t:"lo-hp-pong",n:e.data.n},"*")});addEventListener("pagehide",function(){P.postMessage({t:"lo-hp-left"},"*")})})()</script>'
|
|
95
|
+
const attrOf = (t, n) => (t.match(new RegExp('\\b' + n + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s>]+))', 'i')) || []).slice(1).join(' ')
|
|
96
|
+
export function previewDoc(html, { remote = false } = {}) {
|
|
97
|
+
let s = String(html || '')
|
|
98
|
+
s = s.replace(/<meta\b[^>]*>/gi, (t) => (/^\s*refresh\s*$/i.test(attrOf(t, 'http-equiv')) ? '' : t))
|
|
99
|
+
if (!remote) s = s.replace(/<link\b[^>]*>/gi, (t) => (HINT_RELS.test(attrOf(t, 'rel')) ? '' : t))
|
|
100
|
+
const dt = s.match(/^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i)
|
|
101
|
+
return dt ? dt[0] + GUARD + s.slice(dt[0].length) : GUARD + s
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The bar above the file: Preview · Code, and in Preview what's blocked.
|
|
105
|
+
export function previewBarHtml({ mode = 'preview', refs = [], remote = false, open = false, locals = [], openLocal = false, icon = () => '' } = {}) {
|
|
106
|
+
const seg = (m, label, tip) => '<button type="button" data-action="hp-mode" data-mode="' + m + '" aria-pressed="' + (mode === m) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</button>'
|
|
107
|
+
let h = '<div class="wt-seg wt-seg-sm hp-seg" role="group" aria-label="Show the file as">' + seg('preview', 'Preview', 'The page, rendered in a sandbox') + seg('code', 'Code', 'The file’s source') + '</div>'
|
|
108
|
+
if (mode === 'preview' && refs.length) {
|
|
109
|
+
const hosts = refHosts(refs)
|
|
110
|
+
const n = refs.length
|
|
111
|
+
const words = remote ? 'Loading ' + n + ' remote ' + (n === 1 ? 'resource' : 'resources') : n + ' remote ' + (n === 1 ? 'resource' : 'resources') + ' blocked'
|
|
112
|
+
h += '<div class="hp-remote' + (remote ? ' on' : '') + '">' + icon(remote ? 'globe' : 'shield', 'ic hp-ric') +
|
|
113
|
+
'<span class="hp-rw"><b>' + esc(words) + '</b><span class="hp-hosts"> from ' + esc(hosts.slice(0, 2).join(', ') + (hosts.length > 2 ? ' and ' + (hosts.length - 2) + ' more' : '')) + '</span></span>' +
|
|
114
|
+
'<button type="button" class="btn sm hp-show" data-action="hp-show" aria-expanded="' + !!open + '" aria-controls="hpList' + (locals.length ? ' hpLocal' : '') + '">' + (open ? 'Hide' : 'Show') + '</button>' +
|
|
115
|
+
(remote ? '<button type="button" class="btn sm" data-action="hp-block" data-tip="Reload the preview with remote resources blocked">Block</button>'
|
|
116
|
+
: '<button type="button" class="btn sm" data-action="hp-allow" data-tip="Load these, and anything they load, for this view only">Allow</button>') + '</div>'
|
|
117
|
+
if (open) {
|
|
118
|
+
h += '<ul class="hp-list" id="hpList">' + refs.map((x) => '<li><span class="hp-kind">' + esc(x.kind) + '</span><code>' + esc(x.url) + '</code></li>').join('') + '</ul>' +
|
|
119
|
+
'<p class="hp-note faint">' + (remote ? '' : 'Allowing also lets the page’s scripts load anything else they ask for. ') + 'Links in the page don’t open from the preview.</p>'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (mode === 'preview' && locals.length) {
|
|
123
|
+
// with a remote line above, its Show lists both, so this line is words only
|
|
124
|
+
const n = locals.length, own = !refs.length
|
|
125
|
+
const names = locals.slice(0, 2).map((x) => x.url.split(/[?#]/)[0].split('/').pop() || x.url)
|
|
126
|
+
const shown = own ? openLocal : open
|
|
127
|
+
h += '<div class="hp-remote hp-local' + (own ? '' : ' hp-words') + '">' + icon('file-text', 'ic hp-ric') +
|
|
128
|
+
'<span class="hp-rw"><b>' + n + ' local ' + (n === 1 ? 'file' : 'files') + ' not loaded</b><span class="hp-hosts">: ' + esc(names.join(', ') + (n > 2 ? ' and ' + (n - 2) + ' more' : '')) + '</span></span>' +
|
|
129
|
+
(own ? '<button type="button" class="btn sm hp-show" data-action="hp-show-local" aria-expanded="' + !!openLocal + '" aria-controls="hpLocal">' + (openLocal ? 'Hide' : 'Show') + '</button>' : '') + '</div>'
|
|
130
|
+
if (shown) {
|
|
131
|
+
h += '<ul class="hp-list" id="hpLocal">' + locals.map((x) => '<li><span class="hp-kind">' + esc(x.kind) + '</span><code>' + esc(x.url) + '</code></li>').join('') + '</ul>' +
|
|
132
|
+
'<p class="hp-note faint">The preview has only this file, so files it loads from beside it aren’t there and the page may look unstyled. Open it in a browser from its folder to see it whole.</p>'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return h
|
|
136
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -57,6 +57,7 @@ export const ICONS = {
|
|
|
57
57
|
"layers": "<path d=\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\"/> <path d=\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\"/> <path d=\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\"/>",
|
|
58
58
|
"bot": "<path d=\"M12 8V4H8\"/> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/> <path d=\"M2 14h2\"/> <path d=\"M20 14h2\"/> <path d=\"M15 13v2\"/> <path d=\"M9 13v2\"/>",
|
|
59
59
|
"smartphone": "<rect width=\"14\" height=\"20\" x=\"5\" y=\"2\" rx=\"2\" ry=\"2\"/> <path d=\"M12 18h.01\"/>",
|
|
60
|
+
"globe": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/> <path d=\"M2 12h20\"/>",
|
|
60
61
|
"shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
|
|
61
62
|
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
|
|
62
63
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
package/ui/live-status.js
CHANGED
|
@@ -54,11 +54,49 @@ function resetWords(raw) {
|
|
|
54
54
|
const SIGNIN_WARNING_RE = /^[ \t]*Warning: this sign-in (?:ends between [^\n]*? at the earliest\)\. Renewing it does not move those dates\.|can end at any time from now until [^\n]*?\. Renewing it does not move that date\.|may have ended between [^\n]*?\. If requests are refused,) run claude auth login[^\n]*? or use claude setup-token for unattended jobs\.[ \t]*(?:\r?\n|$)/gim
|
|
55
55
|
export function dropSignInWarning(text) { return String(text == null ? '' : text).replace(SIGNIN_WARNING_RE, '') }
|
|
56
56
|
|
|
57
|
+
// Claude Code's own "signed out" lines, matched specifically, so an MCP server's
|
|
58
|
+
// or another tool's "failed to authenticate", or a 10-day warning whose words
|
|
59
|
+
// drifted from the three above, never reads as signed out. 2.1.318 (read from
|
|
60
|
+
// its package; test/fixtures/cc-2.1.318/from-package.json signedOut):
|
|
61
|
+
// Failed to authenticate: this login reached its time limit … / OAuth session expired …
|
|
62
|
+
// Login reached its time limit · Please run /login
|
|
63
|
+
// plus the API's own authentication_error. Each line is checked on its own, past
|
|
64
|
+
// a "claude -p exited N:" / "claude -p:" prefix (how Ask and the summarizer
|
|
65
|
+
// report it), and a line starting "Warning: this sign-in" is never one.
|
|
66
|
+
export function signInLines(text) {
|
|
67
|
+
return String(text == null ? '' : text).split('\n').filter((l) => !/^\s*Warning: this sign-in\b/i.test(l)).map((l) => l.replace(/^\s*claude -p\b[^:\n]{0,40}:\s*/i, '')).join('\n')
|
|
68
|
+
}
|
|
69
|
+
// 2026-11-05: a line that names an MCP server or another tool is that tool's failure, never Claude signed
|
|
70
|
+
// out, whatever its words ('MCP server "github": HTTP 401 Unauthorized', 'gh: HTTP 401: Requires authentication').
|
|
71
|
+
export const OTHER_TOOL_RE = /\bMCP\b|\bmcp__|\bgh\b|\bgithub\b|\bgitlab\b|\bbitbucket\b|\bjira\b|\bslack\b|\bnpm\b|\bdocker\b|\bcurl\b|\bgit\b|\bplugin\b/i
|
|
72
|
+
const claudeLines = (text) => signInLines(text).split('\n').filter((l) => l.trim() && !OTHER_TOOL_RE.test(l))
|
|
73
|
+
export function claudeAuthFailed(text) {
|
|
74
|
+
return claudeLines(text).some((l) => /^[ \t]*Failed to authenticate:|login reached its time limit|\bauthentication_error\b/i.test(l))
|
|
75
|
+
}
|
|
76
|
+
// Claude's own API refusing the login: an "API Error: 401 …" / "API Error: 403 …" line, or the API's
|
|
77
|
+
// authentication_error / permission_error JSON, on a line that names no other tool. -> 401 | 403 | null.
|
|
78
|
+
// A bare 401, 403, "unauthorized", "forbidden" or "credentials" anywhere else is someone else's.
|
|
79
|
+
export function claudeApiAuthStatus(text) {
|
|
80
|
+
for (const l of claudeLines(text)) {
|
|
81
|
+
if (/\bAPI Error:?\s*401\b|\bauthentication_error\b/i.test(l)) return 401
|
|
82
|
+
if (/\bAPI Error:?\s*403\b|\bpermission_error\b/i.test(l)) return 403
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
// Claude Code's own signed-out words (not tied to a status), line by line, past other tools' lines.
|
|
87
|
+
const CLAUDE_SIGNED_OUT_RE = /invalid api key|run \/login|please (?:log|sign) ?in|not (?:logged|signed) in|log ?in again|login required|oauth token|token (?:has )?expired/i
|
|
88
|
+
export function claudeSignedOut(text) {
|
|
89
|
+
return claudeAuthFailed(text) || claudeApiAuthStatus(text) === 401 || claudeLines(text).some((l) => CLAUDE_SIGNED_OUT_RE.test(l))
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
// 2.1.318: claude -p refuses to start on an invalid config file (it no longer
|
|
58
93
|
// resets it): "Configuration error in <file>: <why>" / "Claude configuration
|
|
59
94
|
// file at <file> is corrupted" / "Fix the JSON in <file>, or move the file aside".
|
|
60
95
|
// -> the file (home shown as ~ when given), or '' when named nowhere, or null.
|
|
61
|
-
|
|
96
|
+
// The path runs to the ".json" that ends it (":", ",", ";", ")", " is corrupted", a
|
|
97
|
+
// sentence's "." , " (" or the line's end), so a folder with a space ("/Users/a b/.claude.json")
|
|
98
|
+
// is kept whole and drifted wording ("….json.", "….json (line 3)", "….json; fix it") still reads.
|
|
99
|
+
const CONFIG_BAD_RE = /(?:Configuration error in|Claude configuration file at|Fix the JSON in) ([^\n]*?\.json)(?=[:,;)]| is corrupted|\.(?:[ \t]|$)|[ \t]+\(|[ \t]*$)/im
|
|
62
100
|
export function claudeConfigError(text, home = '') {
|
|
63
101
|
const m = CONFIG_BAD_RE.exec(String(text == null ? '' : text))
|
|
64
102
|
if (!m) return null
|
|
@@ -78,8 +116,11 @@ export function turnErrorWhy(raw0, { subtype = null, code = null, signal = null,
|
|
|
78
116
|
if (/prompt is too long|context (length|window)|too many tokens|maximum context|input is too long/i.test(raw)) return w('The conversation is too long for Claude · run /compact in its terminal, then retry')
|
|
79
117
|
if (/overloaded|\b529\b/i.test(raw)) return w('Claude’s servers are overloaded · try again in a minute')
|
|
80
118
|
if (/credit balance|billing|payment required|\b402\b/i.test(raw)) return w('Claude’s account is out of credit')
|
|
81
|
-
// 2.1.318's
|
|
82
|
-
|
|
119
|
+
// 2.1.318's own signed-out lines (claudeAuthFailed), never a bare "authenticat" or
|
|
120
|
+
// "claude auth login": an MCP server's auth failure or the 10-day warning isn't a sign-out
|
|
121
|
+
const auth = signInLines(dropSignInWarning(raw0)) // by line, before raw's newlines are folded
|
|
122
|
+
// 2026-11-05: a 401 / 403 counts only from Claude's own API error, never another tool's line (claudeApiAuthStatus)
|
|
123
|
+
if (claudeSignedOut(auth) || claudeApiAuthStatus(auth) === 403) return w(SIGNED_OUT_LABEL)
|
|
83
124
|
if (/timed? ?out|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network|socket hang up|connection (error|refused|reset)/i.test(raw)) return w('Claude couldn’t reach its servers · check this Mac’s connection')
|
|
84
125
|
if (/no conversation found|session .*not found/i.test(raw)) return w('Claude Code couldn’t reopen this conversation')
|
|
85
126
|
const api = raw.match(/API Error:?\s*(\d{3})?/i)
|