@leftoff/cli 0.9.17-beta.8 → 0.9.17
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 +21 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +221 -2
- package/ui/app.js +440 -53
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/icons.js +4 -0
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/side-fold.js +124 -0
- package/ui/walk-lines.js +119 -0
- package/ui/walk-md.js +40 -0
- package/ui/walk-pop.js +24 -5
- package/ui/walk-spots.js +89 -0
package/ui/back-line.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// × hides that one line for this tab (sessionStorage); a new recap or
|
|
6
6
|
// checkpoint brings it back. Pure apart from the store the board passes.
|
|
7
7
|
|
|
8
|
+
import { mdInline } from './changelog-md.js'
|
|
9
|
+
// 0.9.19: the words are Claude's or the summarizer's, so `code` and **bold** render (escaped first, no links)
|
|
10
|
+
const md = (x) => mdInline(x, { links: false })
|
|
8
11
|
const MIN = 60000
|
|
9
12
|
export const BACK_AWAY_MS = 30 * MIN
|
|
10
13
|
export const BACK_PREFIX = 'lo-back-x:'
|
|
@@ -35,8 +38,8 @@ export function backLineHtml(s, line, { esc, icon = () => '', ago = () => '' })
|
|
|
35
38
|
const when = line.at ? ago(line.at) : ''
|
|
36
39
|
const label = line.kind === 'recap' ? 'Recap' : 'Where it stopped'
|
|
37
40
|
const tip = (line.kind === 'recap' ? 'Claude Code’s recap' : 'The latest checkpoint') + (when ? ' · ' + when : '')
|
|
38
|
-
const body = line.kind === 'recap' ?
|
|
39
|
-
: (line.goal ? '<span class="bl-goal">' +
|
|
41
|
+
const body = line.kind === 'recap' ? md(line.text)
|
|
42
|
+
: (line.goal ? '<span class="bl-goal">' + md(line.goal) + '</span> · ' : '') + md(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + md(line.next) + '</span>' : '')
|
|
40
43
|
return '<div class="back-line" role="note" data-key="' + esc(line.key) + '"><p data-tip="' + esc(tip) + '"><span class="rl-k">' + label + '</span> ' + body + '</p>' +
|
|
41
44
|
'<button type="button" class="back-x" data-action="back-dismiss" data-id="' + esc(s.id) + '" data-key="' + esc(line.key) + '" aria-label="Hide this line" data-tip="Hide">' + icon('x') + '</button></div>'
|
|
42
45
|
}
|
package/ui/diff-view.js
CHANGED
|
@@ -238,19 +238,80 @@ export function renderDiff(doc, text, { label = '', numbers = true } = {}) {
|
|
|
238
238
|
const mk = el('span', 'gd-m', MARK[r.k])
|
|
239
239
|
mk.setAttribute('aria-label', r.k === 'add' ? 'added' : r.k === 'del' ? 'removed' : 'unchanged')
|
|
240
240
|
row.appendChild(mk)
|
|
241
|
+
row.appendChild(lineCell(doc, el, r))
|
|
242
|
+
}
|
|
243
|
+
root.appendChild(row)
|
|
244
|
+
}
|
|
245
|
+
return root
|
|
246
|
+
}
|
|
247
|
+
// a line's text, the changed words marked (textContent only)
|
|
248
|
+
function lineCell(doc, el, r) {
|
|
249
|
+
const c = el('span', 'gd-c')
|
|
250
|
+
if (r.w && r.w.length) {
|
|
251
|
+
let at = 0
|
|
252
|
+
for (const [s, e] of r.w) {
|
|
253
|
+
if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
|
|
254
|
+
c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
|
|
255
|
+
at = e
|
|
256
|
+
}
|
|
257
|
+
if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
|
|
258
|
+
} else if (r.t) c.textContent = r.t
|
|
259
|
+
else c.textContent = '\u200b' // keeps an empty line's height
|
|
260
|
+
return c
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 2026-10-30 (walkthrough round 3) the same patch side by side: old on the left,
|
|
264
|
+
// new on the right, a run of removed lines beside the added lines that replaced
|
|
265
|
+
// it (paired in order), unchanged lines on both sides, the @@ bars across. -> the
|
|
266
|
+
// split rows: [{ k: 'line', l: rowIndex | null, r: rowIndex | null } | { k: 'span', i }],
|
|
267
|
+
// indexes into parsePatch's rows (so a picked line means the same in both views).
|
|
268
|
+
export function splitRows(rows) {
|
|
269
|
+
const out = []
|
|
270
|
+
let dels = [], adds = []
|
|
271
|
+
const flush = () => { const n = Math.max(dels.length, adds.length); for (let i = 0; i < n; i++) out.push({ k: 'line', l: i < dels.length ? dels[i] : null, r: i < adds.length ? adds[i] : null }); dels = []; adds = [] }
|
|
272
|
+
rows.forEach((r, i) => {
|
|
273
|
+
if (r.k === 'del') { if (adds.length) flush(); dels.push(i) } else if (r.k === 'add') adds.push(i)
|
|
274
|
+
else { flush(); out.push(r.k === 'ctx' ? { k: 'line', l: i, r: i } : { k: 'span', i }) }
|
|
275
|
+
})
|
|
276
|
+
flush()
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
export function renderSplit(doc, text, { label = '' } = {}) {
|
|
280
|
+
const { rows, maxLine } = parsePatch(text)
|
|
281
|
+
const el = (tag, cls, txt) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (txt != null && txt !== '') e.textContent = txt; return e }
|
|
282
|
+
const root = el('div', 'gd gd-split gd-w' + Math.min(6, Math.max(2, String(maxLine || 1).length)))
|
|
283
|
+
if (label) root.setAttribute('aria-label', label)
|
|
284
|
+
const half = (i, side) => {
|
|
285
|
+
if (i == null) { const h = el('div', 'gd-half gd-h' + side + ' gd-empty'); h.setAttribute('aria-hidden', 'true'); return h }
|
|
286
|
+
const r = rows[i]
|
|
287
|
+
const k = r.k === 'ctx' ? 'ctx' : r.k
|
|
288
|
+
const h = el('div', 'gd-half gd-h' + side + ' gd-' + k)
|
|
289
|
+
h.dataset.ri = String(i)
|
|
290
|
+
const num = side === 'l' ? r.o : r.n
|
|
291
|
+
h.appendChild(el('span', 'gd-gut ' + (side === 'l' ? 'gd-o' : 'gd-n'), num == null ? '' : String(num)))
|
|
292
|
+
const mk = el('span', 'gd-m', MARK[k])
|
|
293
|
+
mk.setAttribute('aria-label', k === 'add' ? 'added' : k === 'del' ? 'removed' : 'unchanged')
|
|
294
|
+
h.appendChild(mk)
|
|
295
|
+
h.appendChild(lineCell(doc, el, r))
|
|
296
|
+
return h
|
|
297
|
+
}
|
|
298
|
+
for (const s of splitRows(rows)) {
|
|
299
|
+
if (s.k === 'span') {
|
|
300
|
+
const r = rows[s.i]
|
|
301
|
+
const row = el('div', 'gd-row gd-' + r.k)
|
|
302
|
+
row.dataset.ri = String(s.i)
|
|
303
|
+
row.appendChild(el('span', 'gd-gut gd-span', r.k === 'gap' ? '⋯' : ''))
|
|
241
304
|
const c = el('span', 'gd-c')
|
|
242
|
-
if (r.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
|
|
246
|
-
c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
|
|
247
|
-
at = e
|
|
248
|
-
}
|
|
249
|
-
if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
|
|
250
|
-
} else if (r.t) c.textContent = r.t
|
|
251
|
-
else c.textContent = '\u200b' // keeps an empty line's height
|
|
305
|
+
if (r.k === 'gap') c.textContent = r.n + ' unchanged line' + (r.n === 1 ? '' : 's')
|
|
306
|
+
else if (r.k === 'hunk') { c.appendChild(el('span', 'gd-range', r.range)); if (r.ctx) { c.appendChild(doc.createTextNode(' ')); c.appendChild(el('span', 'gd-ctx', r.ctx)) } }
|
|
307
|
+
else c.textContent = r.t
|
|
252
308
|
row.appendChild(c)
|
|
309
|
+
root.appendChild(row)
|
|
310
|
+
continue
|
|
253
311
|
}
|
|
312
|
+
const row = el('div', 'gd-srow')
|
|
313
|
+
row.appendChild(half(s.l, 'l'))
|
|
314
|
+
row.appendChild(half(s.r, 'r'))
|
|
254
315
|
root.appendChild(row)
|
|
255
316
|
}
|
|
256
317
|
return root
|
package/ui/file-reach.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 2026-10-29 A file path in a message is a link only when the board can read
|
|
2
|
+
// that file (Joel: "in the chat all files are clickable and open in the modal
|
|
3
|
+
// but not available"). The board reads a session's file on the machine that
|
|
4
|
+
// has it: this machine; another machine over the signed, sealed peer link the
|
|
5
|
+
// Files panel already uses (src/engine.js sessionFile -> viaPeer); or, on the
|
|
6
|
+
// hosted board, straight from that machine through the relay
|
|
7
|
+
// (ui/relay-client.js ownerRoute). Nothing new is opened to the network here:
|
|
8
|
+
// this only decides whether to offer the link. Otherwise the path stays plain
|
|
9
|
+
// monospace text and its tip says why. Pure (test/unit/calmside-1029.test.mjs).
|
|
10
|
+
|
|
11
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
12
|
+
|
|
13
|
+
// s: the session (isLocal, machine, peer: { ok, code }, agent)
|
|
14
|
+
// relay: null off the hosted board; else { macs: [{ machine, online, serves, platform }] } from the relay's status
|
|
15
|
+
// -> { ok: true } | { ok: false, why: 'agent' | 'offline' | 'sandbox' | 'unreachable', tip }
|
|
16
|
+
export function fileReach(s, { relay = null } = {}) {
|
|
17
|
+
if (!s) return { ok: true } // no session to ask about: the viewer says what it can
|
|
18
|
+
if (s.agent || /^agent:/.test(String(s.id || ''))) return { ok: false, why: 'agent', tip: 'External agents don’t share files' }
|
|
19
|
+
if (s.isLocal !== false) return { ok: true }
|
|
20
|
+
const m = String(s.machine || 'its machine')
|
|
21
|
+
const x = relay && Array.isArray(relay.macs) ? relay.macs.find((y) => y && y.machine === m) : null
|
|
22
|
+
const peer = s.peer || {}
|
|
23
|
+
if (x && !x.online) return { ok: false, why: 'offline', tip: 'On ' + m + ' (offline). Open the board on ' + m + ' to view' }
|
|
24
|
+
if (peer.ok || peer.code === 'checking') return { ok: true }
|
|
25
|
+
// the hosted board goes straight to the session's machine when that machine can open this browser's key
|
|
26
|
+
if (x && x.online && x.serves !== 0) return { ok: true }
|
|
27
|
+
if (x && x.online) return { ok: false, why: 'sandbox', tip: x.platform && x.platform !== 'darwin' ? 'On ' + m + '. Can’t read files from a sandbox here; open the board on ' + m + ' to view' : 'On ' + m + ', which can’t share its files with this device yet' }
|
|
28
|
+
if (peer.code === 'unreachable' || !x) return { ok: false, why: peer.code === 'unreachable' ? 'offline' : 'unreachable', tip: peer.code === 'unreachable' ? 'On ' + m + ' (offline). Open the board on ' + m + ' to view' : 'On ' + m + ', which this board can’t reach right now' }
|
|
29
|
+
return { ok: false, why: 'unreachable', tip: 'On ' + m + ', which this board can’t reach right now' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The plain-text form of a path link (the words as shown; the path kept for when it can be read again)
|
|
33
|
+
export function offRefHtml({ path, line = null, shownHtml, tip }, { esc = escD } = {}) {
|
|
34
|
+
return '<span class="file-ref-off" tabindex="0" data-path="' + esc(path) + '"' + (line ? ' data-line="' + esc(String(line)) + '"' : '') + ' data-tip="' + esc(tip) + '" aria-label="' + esc(String(path) + '. ' + tip) + '">' + shownHtml + '</span>'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// In the page: every path link a session's machine can't serve becomes plain
|
|
38
|
+
// text, and back to a link once it can. reachFor(el) -> { ok, tip } for the
|
|
39
|
+
// session that element belongs to. Returns how many changed.
|
|
40
|
+
export function gateFileRefs(root, reachFor) {
|
|
41
|
+
if (!root || !root.querySelectorAll) return 0
|
|
42
|
+
let n = 0
|
|
43
|
+
for (const a of root.querySelectorAll('a.file-ref')) {
|
|
44
|
+
const r = reachFor(a)
|
|
45
|
+
if (!r || r.ok) continue
|
|
46
|
+
const sp = a.ownerDocument.createElement('span')
|
|
47
|
+
sp.className = 'file-ref-off'; sp.tabIndex = 0
|
|
48
|
+
sp.setAttribute('data-path', a.getAttribute('data-path') || '')
|
|
49
|
+
if (a.getAttribute('data-line')) sp.setAttribute('data-line', a.getAttribute('data-line'))
|
|
50
|
+
sp.setAttribute('data-tip', r.tip); sp.setAttribute('aria-label', (a.getAttribute('data-path') || '') + '. ' + r.tip)
|
|
51
|
+
while (a.firstChild) sp.appendChild(a.firstChild)
|
|
52
|
+
a.replaceWith(sp); n++
|
|
53
|
+
}
|
|
54
|
+
for (const sp of root.querySelectorAll('span.file-ref-off')) {
|
|
55
|
+
const r = reachFor(sp)
|
|
56
|
+
if (!r || !r.ok) { if (r && r.tip && sp.getAttribute('data-tip') !== r.tip) sp.setAttribute('data-tip', r.tip); continue }
|
|
57
|
+
const a = sp.ownerDocument.createElement('a')
|
|
58
|
+
a.className = 'file-ref'; a.href = '#'
|
|
59
|
+
a.setAttribute('data-action', 'file-ref'); a.setAttribute('data-path', sp.getAttribute('data-path') || '')
|
|
60
|
+
if (sp.getAttribute('data-line')) a.setAttribute('data-line', sp.getAttribute('data-line'))
|
|
61
|
+
a.setAttribute('data-tip', 'Open in Leftoff')
|
|
62
|
+
while (sp.firstChild) a.appendChild(sp.firstChild)
|
|
63
|
+
sp.replaceWith(a); n++
|
|
64
|
+
}
|
|
65
|
+
return n
|
|
66
|
+
}
|
package/ui/fs-side.js
CHANGED
|
@@ -71,12 +71,12 @@ export function railHtml({ cpCount = 0, cu = true, away = '' } = {}, { esc, icon
|
|
|
71
71
|
// The whole panel. side/foot: the panel's markup (built by app.js from the
|
|
72
72
|
// session); label: the peek's one line; collapsed: the rail instead (desktop);
|
|
73
73
|
// sheet: the phone's sheet is raised.
|
|
74
|
-
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
74
|
+
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, scrolled = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
75
75
|
const peek = '<div class="fs-peek">' +
|
|
76
76
|
'<button type="button" class="fs-peek-b" data-action="fs-sheet" aria-expanded="' + (sheet ? 'true' : 'false') + '" aria-controls="fsSide"><span class="fs-grip" aria-hidden="true"></span><span class="fs-peek-t">' + esc(label) + '</span>' + icon(sheet ? 'chevron-down' : 'chevron-up') + '</button>' +
|
|
77
77
|
(cu ? '<button type="button" class="btn fs-peek-cu' + (away ? ' primary' : '') + '" data-action="scu-open" data-id="' + esc(id) + '"' + (away ? ' aria-label="' + esc(away + ' · Catch me up') + '"' : '') + '>' + icon('coffee') + 'Catch me up</button>' : '') +
|
|
78
78
|
// 0.9.17 Walk me through it, beside Catch me up in the peek (its eye count when there's something to look at)
|
|
79
79
|
(wt ? '<button type="button" class="btn fs-peek-wt" data-action="wt-open" data-id="' + esc(id) + '" aria-label="' + esc('Walk me through it' + (look ? ', ' + look + ' to look closely at' : '')) + '" data-tip="Walk me through the code changes">' + icon('git-compare') + (look ? '<span class="fs-peek-n" aria-hidden="true">' + esc(String(look)) + '</span>' : '') + '</button>' : '') + '</div>'
|
|
80
|
-
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
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
|
}
|
package/ui/icons.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// so they follow light and dark; always aria-hidden next to a text label.
|
|
4
4
|
// index.html carries the nav's copies inline (no script needed to draw them).
|
|
5
5
|
export const ICONS = {
|
|
6
|
+
"crosshair": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M22 12h-4M6 12H2M12 6V2M12 22v-4\"/>",
|
|
7
|
+
"columns-2": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M12 3v18\"/>",
|
|
8
|
+
"chevrons-down-up": "<path d=\"m7 20 5-5 5 5\"/> <path d=\"m7 4 5 5 5-5\"/>",
|
|
9
|
+
"chevrons-up-down": "<path d=\"m7 15 5 5 5-5\"/> <path d=\"m7 9 5-5 5 5\"/>",
|
|
6
10
|
"panel-left-close": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m16 15-3-3 3-3\"/>",
|
|
7
11
|
"panel-left-open": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m14 9 3 3-3 3\"/>",
|
|
8
12
|
"menu": "<path d=\"M4 5h16\"/> <path d=\"M4 12h16\"/> <path d=\"M4 19h16\"/>",
|
package/ui/live-view.js
CHANGED
|
@@ -168,7 +168,7 @@ export function senderLabel(m) {
|
|
|
168
168
|
if (!device && by.transport === 'local') device = 'this Mac'
|
|
169
169
|
if (!device && m.where) { // older entries: just the where line
|
|
170
170
|
const w = String(m.where)
|
|
171
|
-
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
171
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || (w.match(/^from (.+?) · (?:via relay|direct)\b/) || [])[1] || null // 2026-10-29: "from iPhone · via relay"
|
|
172
172
|
}
|
|
173
173
|
let label = 'You' + (device ? ' · from ' + device : '')
|
|
174
174
|
const path = PATHS[by.transport] || null
|
package/ui/prs.js
CHANGED
|
@@ -93,6 +93,11 @@ export async function prCopyClick(btn, env = {}) {
|
|
|
93
93
|
export function prMoreHtml(p, open = false) {
|
|
94
94
|
return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
|
|
95
95
|
}
|
|
96
|
+
// 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
|
|
97
|
+
export function prGhOnlyHtml(p) {
|
|
98
|
+
const url = safeUrl(p.url)
|
|
99
|
+
return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
|
|
100
|
+
}
|
|
96
101
|
export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
97
102
|
const url = safeUrl(p.url), n = '#' + (p.number || '')
|
|
98
103
|
if (!url && !unlinkId) return ''
|
|
@@ -113,21 +118,25 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
113
118
|
const n = list.length + unchecked.length
|
|
114
119
|
let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
|
|
115
120
|
if (list.length) {
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
// 2026-10-29 one compact row each: the state (shape + word), #, the title on one line; tap the title for
|
|
122
|
+
// the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
|
|
123
|
+
h += '<div class="prlist pr-compact">' + list.map((p) => {
|
|
118
124
|
const isOpen = !!(open && open.has && open.has(p.url))
|
|
119
125
|
return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
120
|
-
'<div class="pr-
|
|
121
|
-
'<
|
|
122
|
-
'<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + prMoreHtml(p, isOpen) + '</div>' +
|
|
123
|
-
(isOpen ?
|
|
124
|
-
prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
|
|
126
|
+
'<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
|
|
127
|
+
'<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
|
|
128
|
+
(isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
129
|
+
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
|
|
125
130
|
}).join('') + '</div>'
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
// several refs that couldn't be checked fold into one "+4 not checked" row
|
|
133
|
+
if (unchecked.length === 1) h += '<div class="pr-unlist">' + uncheckedHtml(unchecked[0]) + '</div>'
|
|
134
|
+
else if (unchecked.length > 1) h += '<details class="pr-unfold"><summary class="pr-unsum">' + STATE_IC.unchecked + '<span>+' + unchecked.length + ' not checked</span></summary><div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div></details>'
|
|
128
135
|
if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
|
|
129
136
|
(mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
|
|
130
|
-
|
|
137
|
+
const form = '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
138
|
+
// 2026-10-29: with PRs already linked, the box waits behind one quiet "Link another PR…"
|
|
139
|
+
if (mayEdit) h += n ? '<details class="pr-linkd"><summary class="linkish">Link another PR…</summary>' + form + '</details>' : form
|
|
131
140
|
return h
|
|
132
141
|
}
|
|
133
142
|
// 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
|
package/ui/recap-line.js
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
// says where the session is (the daemon drops it once you prompt again).
|
|
4
4
|
// Before /clear: what this tab was doing before a /clear, with a link to it.
|
|
5
5
|
// Pure: the board passes its own esc / ago / clock, so this runs under node too.
|
|
6
|
+
// 0.9.19: the words are Claude's, so `code` and **bold** render (escaped first, no links).
|
|
7
|
+
import { mdInline } from './changelog-md.js'
|
|
8
|
+
const md = (t) => mdInline(t, { links: false })
|
|
6
9
|
|
|
7
10
|
export function recapLineHtml(s, { esc, ago = () => '', clock = () => '' }) {
|
|
8
11
|
const r = s && s.recap
|
|
9
12
|
if (!r || !r.text) return ''
|
|
10
13
|
const tip = 'Claude Code’s recap' + (r.at ? ' · ' + ago(r.at) + (clock(r.at) ? ' (' + clock(r.at) + ')' : '') : '')
|
|
11
|
-
return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' +
|
|
14
|
+
return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' + md(r.text) + '</p>'
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export function beforeClearHtml(s, { esc }) {
|
|
15
18
|
const b = s && s.beforeClear
|
|
16
19
|
if (!b || !b.goal) return ''
|
|
17
20
|
const href = b.sessionId ? '#/s/' + encodeURIComponent(b.sessionId) : null
|
|
18
|
-
const words =
|
|
21
|
+
const words = md(b.goal) + (b.stoppedAt ? ' <span class="bc-stop">· stopped at: ' + md(b.stoppedAt) + '</span>' : '')
|
|
19
22
|
return '<p class="before-clear"><span class="rl-k">Before /clear</span> ' + (href ? '<a href="' + esc(href) + '" data-tip="The session before /clear">' + words + '</a>' : words) + '</p>'
|
|
20
23
|
}
|