@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/live-view.js
CHANGED
|
@@ -7,6 +7,7 @@ import { statusView, elapsedText, ranWithoutAsking, isQueued } from './live-stat
|
|
|
7
7
|
import { parseAttachedLines } from './attach.js'
|
|
8
8
|
import { stripChannel } from './cp-text.js' // 0.9.9: the one Live-wrapper helper
|
|
9
9
|
import { repoDisplay } from './repos.js' // one repo name everywhere
|
|
10
|
+
import { displayTitle, promptTitleText, whereLine } from './session-title.js' // 2026-11-05 one title helper
|
|
10
11
|
import { copyButtonHtml } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
|
|
11
12
|
|
|
12
13
|
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
@@ -51,10 +52,13 @@ export function pastEmptyText(s, v, { mac = 'this Mac' } = {}) {
|
|
|
51
52
|
export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
52
53
|
if (!s) return untitled
|
|
53
54
|
const wrapped = isWrapped(s.title)
|
|
54
|
-
|
|
55
|
+
// 2026-11-05: what the session is doing, tidied (ui/session-title.js); where
|
|
56
|
+
// it runs is sessionWhere's line, never glued to the front of the name
|
|
57
|
+
if (s.title && !wrapped) return displayTitle(s.title, { source: s.titleSource }) || s.title
|
|
55
58
|
// No name yet (or a raw <channel …> one an earlier beta kept): the first
|
|
56
59
|
// prompt's words, which another Mac's older Leftoff may have sent without a title.
|
|
57
|
-
const
|
|
60
|
+
const raw = promptRaw(s.firstPrompt) || (wrapped ? promptRaw(s.title) : '')
|
|
61
|
+
const first = raw ? promptTitleText(raw) || promptLine(raw) : ''
|
|
58
62
|
if (first && (isLiveSession(s) || wrapped)) return first
|
|
59
63
|
if (isLiveSession(s)) {
|
|
60
64
|
const repo = repoShown(s) || null
|
|
@@ -66,6 +70,13 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
|
66
70
|
return untitled
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
// The muted second line under a session's name: where it runs, "folder@branch"
|
|
74
|
+
// ('' when the name already is just that, or nothing is known).
|
|
75
|
+
export function sessionWhere(s) {
|
|
76
|
+
if (!s || titleIsWhere(s)) return ''
|
|
77
|
+
return whereLine(s, repoShown(s))
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
// Is the card's name just where it runs ("repo · branch")? Then the repo chip
|
|
70
81
|
// under it would say the same thing twice.
|
|
71
82
|
export function titleIsWhere(s) {
|
|
@@ -82,6 +93,11 @@ const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
|
|
|
82
93
|
const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
|
|
83
94
|
// The first line of a prompt in its own words: no channel wrapper (even one
|
|
84
95
|
// cut short), no system reminders; 60 characters at most.
|
|
96
|
+
function promptRaw(text) {
|
|
97
|
+
let t = stripChannel(text).replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
|
|
98
|
+
t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
|
|
99
|
+
return t.replace(/\s+/g, ' ')
|
|
100
|
+
}
|
|
85
101
|
export function promptLine(text) {
|
|
86
102
|
let t = stripChannel(text).replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
|
|
87
103
|
t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
|
|
@@ -246,7 +262,8 @@ export function fullBarHtml(id, { esc, icon, pills = '', stop = '', retitle = ''
|
|
|
246
262
|
// 0.9.18 phones: the nav as a drawer (ui/navrail.js); from 700px the rail is beside full screen and this hides
|
|
247
263
|
'<button type="button" class="btn lf-menu" data-nav-drawer aria-label="Menu" aria-expanded="false" aria-controls="navDrawer" data-tip="Menu">' + icon('menu') + '</button>' +
|
|
248
264
|
'<button type="button" class="btn live-fs-x" data-action="live-full-exit" data-id="' + esc(id) + '" aria-label="Back to the board" data-tip="Board">' + icon('chevron-left') + '</button>' + // 0.9.16 one layout: back is the board
|
|
249
|
-
|
|
265
|
+
// 2026-11-05: what it's doing, then folder@branch muted under it
|
|
266
|
+
glyph + '<div class="lf-tt"><h2 class="lf-title"></h2><span class="lf-where"></span></div>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
|
|
250
267
|
// 0.9.15 the embedded terminal (ui/term.js): docked under full screen, which gets shorter
|
|
251
268
|
'<button type="button" class="btn lf-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + '</button></div>'
|
|
252
269
|
}
|
|
@@ -309,7 +326,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
309
326
|
const who = senderLabel(m)
|
|
310
327
|
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
311
328
|
let body
|
|
329
|
+
const cmd = isCommandEntry(m)
|
|
312
330
|
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
331
|
+
else if (cmd) body = commandHtml(m, { esc })
|
|
313
332
|
else if (m.from === 'you') {
|
|
314
333
|
const a = messageAttachments(m)
|
|
315
334
|
// 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
|
|
@@ -335,6 +354,8 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
335
354
|
// 0.9.11: Copy, once the reply is whole (none while it streams)
|
|
336
355
|
const cp = m.done ? copyButtonHtml(m.text) : ''
|
|
337
356
|
if (cp) foot += '<div class="msg-foot">' + cp + '</div>'
|
|
357
|
+
} else if (cmd && m.status && m.status.phase === 'replied') {
|
|
358
|
+
// ran: what it printed is the body's; nothing to add
|
|
338
359
|
} else if (m.status && m.from === 'you' && isQueued(m.status)) {
|
|
339
360
|
foot = queuedHtml(m, { esc, may })
|
|
340
361
|
} else if (m.status) {
|
|
@@ -352,6 +373,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
352
373
|
return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
|
|
353
374
|
}
|
|
354
375
|
|
|
376
|
+
// A slash command sent from the board (ui/slash-commands.js; src/live-link.js sendCommand): the command
|
|
377
|
+
// in mono, then what Claude Code printed, once the transcript shows it ran (settleCommands).
|
|
378
|
+
export function isCommandEntry(m) { return !!m && m.from === 'you' && m.command === true }
|
|
379
|
+
export function commandHtml(m, { esc }) {
|
|
380
|
+
const ran = !!(m.status && m.status.phase === 'replied')
|
|
381
|
+
const out = typeof m.out === 'string' ? m.out.trim() : ''
|
|
382
|
+
return '<div class="lv-text lv-cmd"><code>' + esc(m.text || '') + '</code></div>' +
|
|
383
|
+
(out ? '<pre class="lv-cmd-out">' + esc(out) + '</pre>' : ran ? '<div class="lv-cmd-out lv-cmd-done">Done</div>' : '')
|
|
384
|
+
}
|
|
385
|
+
|
|
355
386
|
// 0.9.15: an answer that is still the Stop hook's words, clipped by the mod (280 characters ending in "…")
|
|
356
387
|
export function partialAnswer(m) { return !!(m && m.from === 'claude' && m.via === 'turn' && typeof m.text === 'string' && /…\s*$/.test(m.text)) }
|
|
357
388
|
|
|
@@ -548,7 +579,7 @@ export function threadHtml(msgs, opts) {
|
|
|
548
579
|
// 0.9.14: Edit on the message; Retry on Claude's "stopped" card (or on the message, when it didn't arrive)
|
|
549
580
|
const acts = m === tgt ? { retry: m.status.phase === 'failed', edit: true, busy } : null
|
|
550
581
|
html += entryHtml(m, acts ? { ...opts, lvActs: acts } : opts)
|
|
551
|
-
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
|
|
582
|
+
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent' && !isCommandEntry(m)) { // a command has no turn: what it printed is in its own bubble
|
|
552
583
|
// the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
|
|
553
584
|
html += upTo(next == null ? Infinity : next)
|
|
554
585
|
html += pendingClaudeHtml(m, m === tgt ? { ...opts, lvActs: { retry: true, busy } } : opts)
|
|
@@ -647,7 +678,7 @@ export { ranWithoutAsking }
|
|
|
647
678
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
648
679
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
649
680
|
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
650
|
-
export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
|
|
681
|
+
export function entrySig(m) { return JSON.stringify([m.text, m.out == null ? null : m.out, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
|
|
651
682
|
|
|
652
683
|
// ------------------------------------------------------------ the DOM keeper
|
|
653
684
|
|
package/ui/live-wake.js
CHANGED
|
@@ -94,3 +94,50 @@ export function createWatchdog({ now = () => Date.now(), limit = STREAM_DEAD_MS
|
|
|
94
94
|
get attempt() { return attempt },
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
// 2026-11-06 the board catches up when you come back ("opening Leftoff on my phone, if it's been
|
|
99
|
+
// opened and not closed, doesn't always pick up the latest data"). iOS freezes a Home Screen app in
|
|
100
|
+
// the background: its timers stop, its event stream's socket is cut without an error, and on the way
|
|
101
|
+
// back it doesn't always fire visibilitychange, focus or pageshow. Coming back after a few seconds
|
|
102
|
+
// the old stream still looked alive (its last ping was under STREAM_DEAD_MS ago), a refetch already
|
|
103
|
+
// in flight when the page froze held every new one back, and a redraw skipped while you had a draft
|
|
104
|
+
// was never made. So:
|
|
105
|
+
// - the board's one-second timer notices it was frozen (it missed RESUME_GAP_MS) and treats that as
|
|
106
|
+
// coming back, whether or not the page said so
|
|
107
|
+
// - coming back after RECONNECT_AWAY_MS (or the network returning, a restore, a frozen page) always
|
|
108
|
+
// opens a new stream, whatever the old one's last ping said
|
|
109
|
+
// - a relay poll that answers "reset" (the Mac restarted, or another Mac took over) or "gap" (more
|
|
110
|
+
// happened than the daemon keeps) refetches the view, since the events in between are gone
|
|
111
|
+
export const RESUME_GAP_MS = 5000
|
|
112
|
+
export const RECONNECT_AWAY_MS = 10000
|
|
113
|
+
export const FRESH_MS = 30000
|
|
114
|
+
|
|
115
|
+
// How long the page was frozen, by a timer that should have ticked every second: 0 when it ticked.
|
|
116
|
+
export function resumedAfter(prevTick, now = Date.now(), gap = RESUME_GAP_MS) {
|
|
117
|
+
const d = now - (Number(prevTick) || 0)
|
|
118
|
+
return prevTick && d > gap ? d : 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// What a wake-up does: open a new stream (reconnect) and read the view again (refetch).
|
|
122
|
+
// why: wakeReason's word, or 'resume' (the timer saw a freeze); away: ms in the background;
|
|
123
|
+
// dead: the stream missed its pings; freshAt: when the view's data was last known current
|
|
124
|
+
// (a fetch that answered, or the stream's last ping)
|
|
125
|
+
export function catchUp({ why, away = 0, dead = false, freshAt = 0, now = Date.now() } = {}) {
|
|
126
|
+
if (!why) return { reconnect: false, refetch: false }
|
|
127
|
+
const hard = why === 'pageshow-bfcache' || why === 'online' || why === 'resume'
|
|
128
|
+
const reconnect = !!(dead || hard || away >= RECONNECT_AWAY_MS)
|
|
129
|
+
const stale = !freshAt || now - freshAt > FRESH_MS
|
|
130
|
+
const refetch = !!(reconnect || away >= AWAY_MS || stale)
|
|
131
|
+
return { reconnect, refetch }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A relay poll's answer: were events lost between `after` and this answer?
|
|
135
|
+
export function pollLost(after, j) {
|
|
136
|
+
if (!j || after == null || after < 0) return false
|
|
137
|
+
return !!(j.reset || j.gap)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// What the view's data looks like, to tell a refetch that changed something (the quiet "Updated").
|
|
141
|
+
export function viewSignature(sessions, liveSig = '') {
|
|
142
|
+
return (sessions || []).map((s) => [s && s.id, s && s.status, s && s.title, s && s.lastActivityAt, s && s.subagents ? s.subagents.n + ':' + (s.subagents.latest ? s.subagents.latest.at : '') : ''].join(',')).sort().join('|') + '#' + (liveSig || '')
|
|
143
|
+
}
|
package/ui/mcp-setup.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// `leftoff mcp` setup (2026-11-06): the commands you run yourself to let Claude Code or Codex search
|
|
2
|
+
// Leftoff's digests, decisions and checkpoints. Leftoff never writes ~/.claude or ~/.codex; the board
|
|
3
|
+
// shows these with a Copy button, `leftoff doctor` says whether it looks added. Shared by the board and the CLI.
|
|
4
|
+
export const MCP_CLAUDE_LINE = 'claude mcp add --scope user leftoff -- leftoff mcp'
|
|
5
|
+
export const MCP_CODEX_LINE = 'codex mcp add leftoff -- leftoff mcp'
|
|
6
|
+
export const MCP_CODEX_TOML = '[mcp_servers.leftoff]\ncommand = "leftoff"\nargs = ["mcp"]'
|
|
7
|
+
|
|
8
|
+
const row = (cmd, label, esc, icon) => '<div class="mcp-row"><code class="mono">' + esc(cmd) + '</code>' +
|
|
9
|
+
'<button type="button" class="copy-float" data-action="copy-cmd" data-cmd="' + esc(cmd) + '" aria-label="' + esc(label) + '" data-tip="Copy">' + icon('copy') + '</button></div>'
|
|
10
|
+
|
|
11
|
+
// One registration's state, by shape and words: ✓ added, ○ not added, ? couldn't tell.
|
|
12
|
+
export function mcpStateHtml(r, esc) {
|
|
13
|
+
if (!r) return ''
|
|
14
|
+
const one = (who, x) => !x ? '' : x.registered ? '<span class="mcp-state ok">✓ Added to ' + who + (x.scope ? ' (' + esc(x.scope) + ')' : '') + '</span>' : x.error ? '<span class="mcp-state unk">? ' + esc(who) + ': ' + esc(x.error) + '</span>' : '<span class="mcp-state no">○ Not added to ' + who + ' yet</span>'
|
|
15
|
+
return '<div class="mcp-states">' + one('Claude Code', r.claude) + one('Codex', r.codex) + '</div>'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The Settings → Digest part: "Let Claude search your notes".
|
|
19
|
+
export function mcpSetupHtml(reg, { esc, icon }) {
|
|
20
|
+
return '<div class="section" id="mcp">Let Claude search your notes</div>' +
|
|
21
|
+
'<div class="full hint">Claude Code can search your digests, decisions and checkpoints (“what did we decide about the relay?”) through <code>leftoff mcp</code>. It only reads, on this machine; nothing leaves it. Run this once in a terminal:</div>' +
|
|
22
|
+
'<div class="full mcp-box">' + row(MCP_CLAUDE_LINE, 'Copy the Claude Code command', esc, icon) +
|
|
23
|
+
'<details class="mcp-codex"><summary>Using Codex? Show its command</summary>' + row(MCP_CODEX_LINE, 'Copy the Codex command', esc, icon) +
|
|
24
|
+
'<div class="hint">Or add to <code>~/.codex/config.toml</code> yourself:</div><pre class="mono">' + esc(MCP_CODEX_TOML) + '</pre></details>' +
|
|
25
|
+
'<div id="mcpState">' + mcpStateHtml(reg, esc) + '</div></div>'
|
|
26
|
+
}
|
package/ui/prs.js
CHANGED
|
@@ -5,7 +5,7 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
|
|
|
5
5
|
import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst, PROJECT_KEY } from './repos.js'
|
|
6
6
|
import { renderDoc } from './handoffs.js'
|
|
7
7
|
import { COPY_IC, DONE_IC, COPIED_TIP, COPIED_MS, copyIconClick } from './copy-icon.js'
|
|
8
|
-
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
8
|
+
export const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
9
9
|
|
|
10
10
|
export function shortAgo(iso, now = Date.now()) {
|
|
11
11
|
const t = Date.parse(iso || '')
|
|
@@ -93,24 +93,38 @@ 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-11-06 Walk me through this PR: the session's walkthrough opened on this PR's own range (src/walk-pr.js)
|
|
97
|
+
export const WALK_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>'
|
|
98
|
+
export { GH_MARK }
|
|
99
|
+
const OPEN_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
|
|
100
|
+
export const prWalkScope = (p) => (p && p.repo && p.number ? 'pr:' + p.repo + '#' + p.number : null)
|
|
101
|
+
// One Open button: a menu asking where (Walk me through it in Leftoff, or GitHub) when the PR has a walkthrough;
|
|
102
|
+
// else the plain GitHub link. (Joel, 2026-11-06: four glyphs took the title's room.)
|
|
103
|
+
export function prOpenHtml(p, walkId) {
|
|
104
|
+
const url = safeUrl(p.url), sc = walkId && prWalkScope(p), n = '#' + (p.number || '')
|
|
105
|
+
if (sc) return '<button type="button" class="pr-ic pr-open" data-action="pr-open" data-id="' + esc(walkId) + '" data-scope="' + esc(sc) + '" data-url="' + esc(url || '') + '" data-repo="' + esc(p.repo || '') + '" data-num="' + esc(p.number) + '" aria-haspopup="menu" data-tip="Open in Leftoff or on GitHub" aria-label="' + esc('Open ' + n + '…') + '">' + OPEN_IC + '</button>'
|
|
106
|
+
return url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : ''
|
|
107
|
+
}
|
|
96
108
|
// 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
// (2026-11-06 Open asks Leftoff or GitHub, when the row is a session's)
|
|
110
|
+
export function prGhOnlyHtml(p, { walkId = null } = {}) {
|
|
111
|
+
const open = prOpenHtml(p, walkId)
|
|
112
|
+
return open ? '<div class="pr-actions">' + open + '</div>' : ''
|
|
100
113
|
}
|
|
101
|
-
export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
114
|
+
export function prActionsHtml(p, { unlinkId = null, walkId = null } = {}) {
|
|
102
115
|
const url = safeUrl(p.url), n = '#' + (p.number || '')
|
|
103
116
|
if (!url && !unlinkId) return ''
|
|
104
117
|
const copyLabel = 'Copy link to ' + n
|
|
105
118
|
return '<div class="pr-actions">' +
|
|
106
119
|
(url ? '<button type="button" class="pr-ic pr-copy" data-action="pr-copy" data-copy="' + esc(url) + '" data-tip="' + PR_COPY_TIP + '" data-label="' + esc(copyLabel) + '" aria-label="' + esc(copyLabel) + '">' + COPY_IC + DONE_IC + '</button>' : '') +
|
|
107
|
-
(
|
|
120
|
+
prOpenHtml(p, walkId) +
|
|
108
121
|
(unlinkId ? '<button type="button" class="pr-ic pr-unlink" data-action="pr-unlink" data-id="' + esc(unlinkId) + '" data-url="' + esc(p.url) + '" data-tip="Unlink from this session" aria-label="Unlink ' + esc(n) + ' from this session">' + UNLINK + '</button>' : '') +
|
|
109
122
|
'</div>'
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
113
126
|
export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
|
|
127
|
+
const walkId = s && !s.agent ? s.id : null
|
|
114
128
|
// 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
|
|
115
129
|
const more = (s.prsMore || []).filter((p) => p && p.number)
|
|
116
130
|
const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
|
|
@@ -122,11 +136,13 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
122
136
|
// the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
|
|
123
137
|
h += '<div class="prlist pr-compact">' + list.map((p) => {
|
|
124
138
|
const isOpen = !!(open && open.has && open.has(p.url))
|
|
125
|
-
|
|
139
|
+
// how many glyphs sit at the right (the title line leaves them room when open)
|
|
140
|
+
const acts = isOpen ? (safeUrl(p.url) ? 1 : 0) + (safeUrl(p.url) || (prWalkScope(p) && walkId) ? 1 : 0) + (mayEdit ? 1 : 0) : 0
|
|
141
|
+
return '<article class="prrow slim' + (isOpen ? ' open pr-acts-' + acts : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
126
142
|
'<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
|
|
127
143
|
'<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>'
|
|
144
|
+
(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) ? ' · ' + checksHtml(p.checks) : '') + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
145
|
+
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null, walkId }) : prGhOnlyHtml(p, { walkId })) + '</article>'
|
|
130
146
|
}).join('') + '</div>'
|
|
131
147
|
}
|
|
132
148
|
// several refs that couldn't be checked fold into one "+4 not checked" row
|
|
@@ -198,12 +214,14 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
|
|
|
198
214
|
: [p.repo, p.branch, when].filter(Boolean)
|
|
199
215
|
const why = whyText(r)
|
|
200
216
|
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
217
|
+
// 2026-11-06 Walk me through this PR: in its newest session's walkthrough (never a scripted run's)
|
|
218
|
+
const walkTo = !team ? (r.sessions || []).find((x) => x && x.id && !x.nonInteractive && x.archivedReason !== 'non_interactive' && !x.agent) : null
|
|
201
219
|
return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
|
|
202
220
|
'<div class="pr-line1">' + line1 + '</div>' +
|
|
203
221
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
204
222
|
'<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
|
|
205
223
|
sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
|
|
206
|
-
prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null }) + '</article>'
|
|
224
|
+
prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null, walkId: walkTo ? walkTo.id : null }) + '</article>'
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
// ---------------------------------------------------------------- 0.8.5: details on tap
|
package/ui/pull.js
CHANGED
|
@@ -7,17 +7,44 @@ export const PULL_MAX = 110 // how far the spinner travels at most
|
|
|
7
7
|
|
|
8
8
|
// Where a pull never starts: text fields, the Live box, menus and sheets,
|
|
9
9
|
// sideways scrollers, and anything open on top of the page.
|
|
10
|
-
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
|
|
10
|
+
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, .fs-side, [data-no-pull]'
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// 2026-11-06 ("it's really hard to do that when you're at the bottom of a long message chain, because
|
|
13
|
+
// you have to scroll all the way to the top"): the bars at the top of the screen are a handle of their
|
|
14
|
+
// own. A pull that starts on the top row, a session's head or the Live box's head refreshes wherever
|
|
15
|
+
// the page or the chat is scrolled to. Fields, menus, sideways scrollers and the sheets still never
|
|
16
|
+
// start one (PULL_HEAD_SKIP: PULL_SKIP without the Live box, whose head is one of the bars).
|
|
17
|
+
export const PULL_HEAD = 'header.top, .s-head, .fs-head, .live-head, .live-fbar' // .live-fbar: full-screen Live's slim bar
|
|
18
|
+
export const PULL_HEAD_SKIP = PULL_SKIP.split(', ').filter((x) => x !== '.live-box' && x !== 'pre').join(', ') + ', button.chip-x, [role="slider"]'
|
|
19
|
+
export function onPullHead(target) {
|
|
20
|
+
if (!target || typeof target.closest !== 'function') return false
|
|
21
|
+
return !!target.closest(PULL_HEAD) && !target.closest(PULL_HEAD_SKIP)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The phone layout on a touch screen, at the very top of the page (or on one of
|
|
25
|
+
// the bars at the top, onPullHead), from a place that isn't a field or an
|
|
26
|
+
// overlay, and with no overlay open.
|
|
14
27
|
export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
|
|
15
28
|
if (!coarse || !narrow || overlayOpen) return false
|
|
29
|
+
if (onPullHead(target)) return true
|
|
16
30
|
if (scrollY > 0) return false
|
|
17
31
|
if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
|
|
32
|
+
if (scrolledAbove(target)) return false
|
|
18
33
|
return true
|
|
19
34
|
}
|
|
20
35
|
|
|
36
|
+
// 2026-11-06 (the phone's session sheet "scrolls down okay, but I cannot scroll back up. It's very
|
|
37
|
+
// jumpy"): in full screen the page itself never scrolls (scrollY is 0), so a finger moving down
|
|
38
|
+
// inside the sheet's own scroller was taken as a pull to refresh: the board cancelled the scroll and
|
|
39
|
+
// showed the spinner instead. A pull only starts where nothing between the finger and the page is
|
|
40
|
+
// itself scrolled down: a scroller with room above means the finger is scrolling it back up.
|
|
41
|
+
export function scrolledAbove(el) {
|
|
42
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
43
|
+
if ((Number(n.scrollTop) || 0) > 0) return true
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
// One finger movement: how far the spinner sits (with some resistance), how
|
|
22
49
|
// far round it is, and whether letting go now refreshes. Sideways first means
|
|
23
50
|
// it isn't a pull.
|
package/ui/relay-client.js
CHANGED
|
@@ -364,7 +364,9 @@ class RelayEventSource extends EventTarget {
|
|
|
364
364
|
if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
|
|
365
365
|
this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
|
|
366
366
|
const first = this.seq == null
|
|
367
|
+
const lost = !first && (j.reset || j.gap) // 2026-11-06: events this browser never got (the Mac restarted, another took over, or more happened than it keeps)
|
|
367
368
|
this.seq = j.seq
|
|
369
|
+
if (lost) this.dispatchEvent(new MessageEvent('resync', { data: JSON.stringify({ reset: !!j.reset, gap: !!j.gap }) }))
|
|
368
370
|
if (!first) for (const e of j.events || []) {
|
|
369
371
|
const ev = new MessageEvent(e.event, { data: JSON.stringify(e.data) })
|
|
370
372
|
this.dispatchEvent(ev)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The digest's markdown as HTML: one renderer for the board (ui/app.js) and for
|
|
2
|
+
// `leftoff export digest --html` (ui/render-export.js, through node), so both say
|
|
3
|
+
// the same thing. 2026-11-06 moved here from ui/app.js's renderMd, unchanged for the
|
|
4
|
+
// board: its Go / Resume buttons, session links and Copy for standup come in as hooks
|
|
5
|
+
// (action, href, standup); an exported file passes none of them and gets plain words.
|
|
6
|
+
// No DOM, no imports: runs in a browser and in node alike.
|
|
7
|
+
|
|
8
|
+
export const escR = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
export const boardHref = (id) => '#/s/' + encodeURIComponent(id)
|
|
10
|
+
|
|
11
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
12
|
+
// links there and Start here gets that session's Go / Resume.
|
|
13
|
+
export const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
14
|
+
|
|
15
|
+
export function digestBodyHtml(md, { spaces = [], esc = escR, href = boardHref, action = null, standup = null, decLinks = true } = {}) {
|
|
16
|
+
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
17
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
18
|
+
const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, decLinks ? '<a class="dg-dec" href="$2">$1</a>' : '<span class="dg-dec">$1</span>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
19
|
+
const out = []
|
|
20
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false, inDone = false
|
|
21
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
22
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
23
|
+
// a session's link: the board's #/s/<id>; an exported file has none (href returns null)
|
|
24
|
+
const link = (id, html, cls = '') => { const u = id && href ? href(id) : null; return u ? '<a' + (cls ? ' class="' + cls + '"' : '') + ' href="' + esc(u) + '">' + html + '</a>' : (cls ? '<span class="' + cls + '">' + html + '</span>' : html) }
|
|
25
|
+
const act = (id) => (id && action ? action(id) : '')
|
|
26
|
+
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
27
|
+
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
28
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
29
|
+
const openSec = (h) => {
|
|
30
|
+
if (inSec) out.push('</section>')
|
|
31
|
+
inSec = true
|
|
32
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
33
|
+
else if (h === 'What you got done') out.push('<section class="done-sec"><h2>' + inline(h) + '</h2>') // 2026-11-06
|
|
34
|
+
else if (spaces.includes(h)) {
|
|
35
|
+
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
36
|
+
(standup ? standup(h) : '') + '</div>')
|
|
37
|
+
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
38
|
+
}
|
|
39
|
+
for (const raw of body.split('\n')) {
|
|
40
|
+
const mid = raw.match(MD_SID)
|
|
41
|
+
const id = mid ? mid[1] : null
|
|
42
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
43
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
44
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
45
|
+
if (st) {
|
|
46
|
+
openList('ol', 'start')
|
|
47
|
+
const t = id ? link(id, inline(st[2])) : inline(st[2])
|
|
48
|
+
out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
|
|
49
|
+
(id && action ? '<div class="sh-act">' + act(id) + '</div>' : '') + '</li>')
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
// 2026-11-06: What you got done: "- Merged PR #53: …", "- Still open: **Title**, next: …" (its session linked)
|
|
53
|
+
const dn = inDone && /^- /.test(line) && line.match(/^- (Merged|Opened|Set to merge when checks pass|Also committed|Committed|Tests passed|Tests failing|Decided|Waiting on you|Still open|\+\d+ more decisions)\b(.*)$/)
|
|
54
|
+
if (dn) {
|
|
55
|
+
openList('ul', 'done')
|
|
56
|
+
const kind = { 'Merged': 'merged', 'Opened': 'opened', 'Set to merge when checks pass': 'opened', 'Also committed': 'commit', 'Committed': 'commit', 'Tests passed': 'pass', 'Tests failing': 'fail', 'Decided': 'decided', 'Waiting on you': 'you', 'Still open': 'open' }[dn[1]] || 'decided'
|
|
57
|
+
const so = id && dn[2].match(/^: \*\*(.+?)\*\*(.*)$/)
|
|
58
|
+
const rest = so ? ': ' + link(id, inline(so[1])) + inline(so[2]) : inline(dn[2])
|
|
59
|
+
out.push('<li class="dn dn-' + kind + '"' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><b>' + esc(dn[1]) + '</b>' + rest + '</li>')
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (/^- /.test(line)) {
|
|
63
|
+
openList('ul')
|
|
64
|
+
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
65
|
+
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
66
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
67
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
68
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
69
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
70
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
71
|
+
if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head">' + link(id, inline(bi[1]), 'bi-title') + '<span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div>' + (action ? '<div class="sh-act">' + act(id) + '</div>' : '')); continue }
|
|
72
|
+
if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head">' + link(id, inline(bi[1]), 'bi-title') + '<span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
|
|
73
|
+
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
74
|
+
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
75
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
76
|
+
}
|
|
77
|
+
if (/^\s{2,}\S/.test(line) && inList) {
|
|
78
|
+
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
79
|
+
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
80
|
+
if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
|
|
81
|
+
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
closeList()
|
|
85
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
86
|
+
if (/^## /.test(line)) inDone = line.slice(3).trim() === 'What you got done'
|
|
87
|
+
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
88
|
+
else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here' || h === 'What you got done') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
|
|
89
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
90
|
+
else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
|
|
91
|
+
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
92
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
93
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
94
|
+
}
|
|
95
|
+
closeList()
|
|
96
|
+
if (inSec) out.push('</section>')
|
|
97
|
+
return out.join('\n')
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// One self-contained .html file for a digest, a decision log or a walkthrough
|
|
2
|
+
// (2026-11-06, phase 2b). The board's "Save as HTML" and `leftoff export … --html`
|
|
3
|
+
// both call these, so the file is the same from either. The file carries its CSS
|
|
4
|
+
// inline and nothing else: no scripts, fonts, images or links out (a Content
|
|
5
|
+
// Security Policy says so too), light and dark from the reader's own setting,
|
|
6
|
+
// readable on a phone, and it prints. No DOM and no node-only imports: runs in a
|
|
7
|
+
// browser and in node.
|
|
8
|
+
import { digestBodyHtml, escR as esc } from './render-digest.js'
|
|
9
|
+
import { EXPORT_CSS, toHtmlDoc, exportName as wtExportName } from './semdiff.js'
|
|
10
|
+
import { STATUS_SHAPE, STATUS_WORD, SOURCE_WORDS, timelineRows } from './decisions.js'
|
|
11
|
+
|
|
12
|
+
export const EXPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'"
|
|
13
|
+
|
|
14
|
+
// A time as words, the same on the board and the CLI (en-US, the reader's own zone unless one is given)
|
|
15
|
+
export function exportWhen(iso, { timeZone } = {}) {
|
|
16
|
+
const t = Date.parse(iso || '')
|
|
17
|
+
if (!Number.isFinite(t)) return ''
|
|
18
|
+
return new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timeZone ? { timeZone } : {}) })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A file name for the download / the CLI's default: "leftoff-digest-2026-11-06.html"
|
|
22
|
+
export function exportFileName(kind, label = '') {
|
|
23
|
+
const slug = String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48)
|
|
24
|
+
if (kind === 'walkthrough') return wtExportName(label)
|
|
25
|
+
return 'leftoff-' + kind + (slug ? '-' + slug : '') + '.html'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The page around a body: the same head for every export.
|
|
29
|
+
export function exportDoc({ title, css = '', body }) {
|
|
30
|
+
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
31
|
+
'<meta name="color-scheme" content="light dark"><meta http-equiv="Content-Security-Policy" content="' + esc(EXPORT_CSP) + '">' +
|
|
32
|
+
'<title>' + esc(title) + '</title><style>' + EXPORT_CSS + PAGE_CSS + css + '</style></head><body><main>' + body + '</main></body></html>\n'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared by the digest and the decision log: phone width, print, reduced motion (nothing moves anyway)
|
|
36
|
+
const PAGE_CSS = 'html{-webkit-text-size-adjust:100%;text-size-adjust:100%}main{padding:24px 16px 48px}h3{font-size:15px;margin:18px 0 4px}p{margin:0 0 10px;overflow-wrap:anywhere}li{overflow-wrap:anywhere}' +
|
|
37
|
+
'.foot{margin-top:40px;padding-top:10px;border-top:1px solid #d1d9e0;color:#59636e;font-size:12px}' +
|
|
38
|
+
'@media (min-width:600px){main{padding:32px 24px 64px}}' +
|
|
39
|
+
'@media print{body{background:#fff;color:#000;font-size:12pt}main{max-width:none;padding:0}section,li{break-inside:avoid}a{color:inherit;text-decoration:none}.foot{color:#555}}' +
|
|
40
|
+
'@media (prefers-color-scheme:dark){.foot{border-color:#3d444d;color:#9198a1}}'
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- the digest
|
|
43
|
+
|
|
44
|
+
const DIGEST_CSS = '.lede{font-size:16px;margin:0 0 10px}.meta{color:#59636e;font-size:13px;margin:0 0 18px}' +
|
|
45
|
+
'section{margin:22px 0 0}section.done-sec{margin:16px 0 4px;padding:10px 14px 6px;border:1px solid #d1d9e0;border-radius:10px;background:#f6f8fa}' +
|
|
46
|
+
'section.done-sec h2{margin:2px 0;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#59636e}section.done-sec h3{margin:12px 0 4px;font-size:14px}' +
|
|
47
|
+
'ul.done{list-style:none;margin:0;padding:0}li.dn{position:relative;padding:3px 0 3px 22px;line-height:1.45}li.dn b{font-weight:600}' +
|
|
48
|
+
'li.dn::before{position:absolute;left:2px;top:3px;width:16px;text-align:center;font-weight:700;color:#59636e}' +
|
|
49
|
+
"li.dn-merged::before{content:'✓'}li.dn-opened::before{content:'○'}li.dn-commit::before{content:'•'}li.dn-pass::before{content:'✓'}li.dn-fail::before{content:'✗'}li.dn-decided::before{content:'◆';font-size:11px}li.dn-you::before{content:'!'}li.dn-open::before{content:'→'}li.dn-more::before{content:'+'}" +
|
|
50
|
+
'ol.start{padding-left:22px}ol.start li{margin:6px 0}.sh-main{display:flex;flex-direction:column;gap:2px}.sh-step{font-weight:600}.sh-sess{color:#59636e;font-size:14px}.sh-repo,.where,.bi-tag{color:#59636e;font-size:13px}' +
|
|
51
|
+
'ul{padding-left:20px}ul li{margin:6px 0}.bi-head{display:flex;flex-wrap:wrap;gap:4px 8px;align-items:baseline}.bi-title{font-weight:600}.bi-say{display:block}.sub{display:block;color:#59636e;font-size:14px}.sub .k{font-weight:600;margin-right:4px}' +
|
|
52
|
+
'.space-head h2{margin:0}details.fin summary{min-height:44px;display:flex;align-items:center}' +
|
|
53
|
+
'@media print{details.fin>*{display:block}section.done-sec{background:none}}' +
|
|
54
|
+
'@media (prefers-color-scheme:dark){section.done-sec{background:#161b22;border-color:#3d444d}.meta,.sh-sess,.sh-repo,.where,.bi-tag,.sub,section.done-sec h2,li.dn::before{color:#9198a1}}'
|
|
55
|
+
|
|
56
|
+
const frontmatter = (md) => {
|
|
57
|
+
const m = String(md || '').match(/^---\n([\s\S]*?)\n---\n/)
|
|
58
|
+
const out = {}
|
|
59
|
+
if (m) for (const l of m[1].split('\n')) { const kv = l.match(/^([a-z_]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim() }
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
const listOf = (v) => (/^\[.*\]$/.test(v || '') ? v.slice(1, -1).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
|
63
|
+
|
|
64
|
+
// md: the digest file as it is on disk (front matter and all). The session
|
|
65
|
+
// buttons, links into the board and Copy for standup are the board's; the file
|
|
66
|
+
// has the words only.
|
|
67
|
+
export function digestExportHtml(md, { date = null, timeZone } = {}) {
|
|
68
|
+
const fm = frontmatter(md)
|
|
69
|
+
const day = date || fm.date || ''
|
|
70
|
+
const made = exportWhen(fm.generated_at, { timeZone })
|
|
71
|
+
const meta = '<p class="meta">' + esc(['Daily digest', day, made ? 'made ' + made : ''].filter(Boolean).join(' · ')) + '</p>'
|
|
72
|
+
let body = digestBodyHtml(String(md || ''), { spaces: listOf(fm.spaces), href: null, action: null, standup: null, decLinks: false })
|
|
73
|
+
.replace(/<details class="fin">/g, '<details class="fin" open>') // a file is read whole (and printed): Finished starts open
|
|
74
|
+
body = /<h1>[\s\S]*?<\/h1>/.test(body) ? body.replace(/(<h1>[\s\S]*?<\/h1>)/, (h) => h + meta) : '<h1>Daily digest</h1>' + meta + body
|
|
75
|
+
return exportDoc({ title: 'Digest · ' + day, css: DIGEST_CSS, body: body + foot() })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------- the decision log
|
|
79
|
+
|
|
80
|
+
const DEC_CSS = '.dl{list-style:none;margin:0;padding:0}.dl>li{padding:12px 0;border-top:1px solid #d1d9e0}.dl>li:first-child{border-top:0}' +
|
|
81
|
+
'.dh{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;font-size:13px;color:#59636e}.dh .id{font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.st{color:#1f2328;font-weight:600}' +
|
|
82
|
+
'.dt{font-size:15px;margin:4px 0 2px}.dw,.da,.dc,.dm{font-size:14px;color:#59636e;margin:2px 0}.count{color:#59636e;font-weight:400}' +
|
|
83
|
+
'.dl>li.superseded .dt,.dl>li.rejected .dt{text-decoration:line-through;text-decoration-thickness:1px}' +
|
|
84
|
+
'@media (prefers-color-scheme:dark){.dl>li{border-color:#3d444d}.dh,.dw,.da,.dc,.dm,.count{color:#9198a1}.st{color:#e6edf3}}'
|
|
85
|
+
|
|
86
|
+
const dday = (iso) => String(iso || '').slice(0, 10)
|
|
87
|
+
function decisionItem(d, byId) {
|
|
88
|
+
const st = STATUS_SHAPE[d.status] ? '<span class="st"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>' : ''
|
|
89
|
+
const chain = []
|
|
90
|
+
for (const id of d.supersedes || []) { const o = byId.get(id); chain.push('Replaces ' + esc(id) + (o ? ' (' + esc(dday(o.createdAt)) + ': ' + esc(o.text) + ')' : '')) }
|
|
91
|
+
if (d.supersededBy) chain.push('Superseded by ' + esc(d.supersededBy) + (d.supersededAt ? ' on ' + esc(dday(d.supersededAt)) : ''))
|
|
92
|
+
const tp = /^testplan:(tp_[0-9a-f]{16})#c(\d+)$/.exec(String(d.checkpointId || ''))
|
|
93
|
+
const from = tp ? 'From a test plan (check ' + esc(tp[2]) + ')' : d.session && d.session.title ? 'From ' + esc(d.session.title) + (d.session.pr && d.session.pr.number ? ' · PR #' + esc(d.session.pr.number) : '') : ''
|
|
94
|
+
const meta = [esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent), from].filter(Boolean).join(' · ')
|
|
95
|
+
return '<li class="' + esc(d.status) + '"><div class="dh"><span>' + esc(dday(d.createdAt)) + '</span><span class="id">' + esc(d.id) + '</span>' + (d.repoLabel && d.multi ? '<span>' + esc(d.repoLabel) + '</span>' : '') + st + '</div>' +
|
|
96
|
+
'<div class="dt">' + esc(d.text) + (d.edited ? ' <span class="dm">(edited)</span>' : '') + '</div>' +
|
|
97
|
+
(d.why ? '<div class="dw">' + esc(d.why) + '</div>' : '') +
|
|
98
|
+
(d.alternatives && d.alternatives.length ? '<div class="da">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
99
|
+
(chain.length ? '<div class="dc">' + chain.join(' · ') + '</div>' : '') +
|
|
100
|
+
'<div class="dm">' + meta + '</div></li>'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// v: GET /v1/decisions (engine.decisionsView). all: superseded and rejected too.
|
|
104
|
+
export function decisionsExportHtml(v, { all = false } = {}) {
|
|
105
|
+
const list = (v && v.decisions) || []
|
|
106
|
+
const byId = new Map(list.map((d) => [d.id, d]))
|
|
107
|
+
const multi = ((v && v.repos) || []).length > 1
|
|
108
|
+
const sc = (v && v.scope) || {}
|
|
109
|
+
const name = (sc.kind === 'project' ? sc.name : sc.label) || sc.id || ''
|
|
110
|
+
const { review, rows } = timelineRows(list, { all })
|
|
111
|
+
const item = (d) => decisionItem({ ...d, multi }, byId)
|
|
112
|
+
let b = '<h1>Decisions' + (name ? ' · ' + esc(name) : '') + '</h1>'
|
|
113
|
+
b += '<p class="meta f">' + esc([multi ? v.repos.map((r) => r.label).join(' · ') : '', all ? 'Accepted, superseded and rejected' : 'Accepted decisions'].filter(Boolean).join(' · ')) + '</p>'
|
|
114
|
+
if (review.length) b += '<section><h2>To review <span class="count">' + review.length + '</span></h2><ol class="dl">' + review.map(item).join('') + '</ol></section>'
|
|
115
|
+
b += '<section><h2>' + (all ? 'Timeline' : 'Decided') + ' <span class="count">' + rows.length + '</span></h2>' + (rows.length ? '<ol class="dl">' + rows.map(item).join('') + '</ol>' : '<p class="f">Nothing accepted yet.</p>') + '</section>'
|
|
116
|
+
return exportDoc({ title: 'Decisions' + (name ? ' · ' + name : ''), css: DEC_CSS, body: b + foot() })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------- a walkthrough
|
|
120
|
+
|
|
121
|
+
// data: the walkthrough (POST /v1/sessions/<id>/walkthrough); s: its session ({ title, repoLabel, repoName, repo: { branch } })
|
|
122
|
+
export function walkthroughExportHtml(data, s = {}, { timeZone } = {}) {
|
|
123
|
+
const title = (s && s.title) || 'Session'
|
|
124
|
+
const repo = [s.repoLabel || s.repoName || '', s.repo && s.repo.branch].filter(Boolean).join(' · ')
|
|
125
|
+
return toHtmlDoc(data, { title, repo, when: data && data.generatedAt ? exportWhen(data.generatedAt, { timeZone }) : '' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const foot = () => '<p class="foot">Saved from Leftoff. One file: it opens offline and asks nothing of the network.</p>'
|