@leftoff/cli 0.9.18-beta.8 → 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/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
- if (s.title && !wrapped) return s.title
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 first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
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
- glyph + '<h2 class="lf-title"></h2>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
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
+ }
@@ -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
- 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>' : ''
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
- (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>' : '') +
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
- return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
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
- // The phone layout on a touch screen, at the very top of the page, from a
13
- // place that isn't a field or an overlay, and with no overlay open.
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.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>'