@leftoff/cli 0.9.18-beta.13 → 0.9.18-beta.14

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/app.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Leftoff web UI: plain JS, no build step, no CDN. Talks to the daemon's
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
+ import { mcpSetupHtml, mcpStateHtml } from './mcp-setup.js'
5
+ import { paintDigestVersion } from './digest-versions.js'
4
6
  import { icon } from './icons.js'
5
7
  import { fullViewportFit, isTypingTarget, outOfView, sheetAside, keyboardAccessoryPx, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS, FOLLOW_FRAMES_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
6
8
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
@@ -58,15 +60,16 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
58
60
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
59
61
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
60
62
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
61
- import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine, isPrKey, prScopeTip } from './semdiff.js' // 0.9.17 Walk me through it
63
+ import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, revNextTip, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine, isPrKey, prScopeTip } from './semdiff.js' // 0.9.17 Walk me through it
62
64
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
63
- import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
65
+ import { spotsFor, spotFrom, spotTip, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
64
66
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
65
67
  import { lastReply, ghostShows } from './ghost.js'
66
68
  import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
67
69
  import { digestHeadline, todayHidden, hideToday, todayCardHtml } from './today.js' // 0.9.16 the board's Today card // 0.9.16 the suggested next message // 0.9.16 the session panel beside full screen
68
70
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
69
71
  import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
72
+ import { isHtmlDoc, remoteRefs, previewDoc, previewBarHtml, PREVIEW_PAGE, VIEW_KEY as HP_VIEW_KEY } from './html-preview.js'
70
73
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
71
74
  import { widState, whatItDidSection, copyText as widCopyText, loadError as widError, hiddenText as widHidden, slotSource as widSlot } from './what-it-did.js' // 0.9.8 What it did
72
75
  import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
@@ -79,7 +82,9 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
79
82
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
80
83
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
81
84
  import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
82
- import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
85
+ import { cpFoldSummary, workItems } from './cp-fold.js' // 2026-11-05 the fold names what's inside
86
+ import { digestBodyHtml } from './render-digest.js' // 2026-11-06 the digest renderer, shared with leftoff export
87
+ import { digestExportHtml, decisionsExportHtml, walkthroughExportHtml, exportFileName } from './render-export.js' // 2026-11-06 Save as HTML (the same file leftoff export writes)
83
88
  import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
84
89
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
85
90
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
@@ -540,11 +545,15 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
540
545
  }
541
546
  // 0.9.15: what the session is for and how far along, above its latest step
542
547
  const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
543
- if (part === 'tail') return '<dl class="cp cp-tail' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '"><dt>Stopped</dt>' + dd(cp.stoppedAt) + (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') + '</dl>'
548
+ // 2026-11-06: what the session's commands show got done (commits, PRs, tests), above where it stopped
549
+ const wl = workItems(cp)
550
+ const doneRow = wl.length ? '<dt>Got done</dt><dd class="cp-done"><ul>' + wl.map((x) => '<li class="dn dn-' + x.kind + '">' + fmtCode(x.text) + '</li>').join('') + '</ul></dd>' : ''
551
+ if (part === 'tail') return '<dl class="cp cp-tail' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' + doneRow + '<dt>Stopped</dt>' + dd(cp.stoppedAt) + (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') + '</dl>'
544
552
  if (part === 'head') return (goal || String(cp.doing || '').trim() ? '<dl class="cp cp-head">' + (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') + (String(cp.doing || '').trim() ? '<dt>Doing</dt>' + dd(cp.doing) : '') + '</dl>' : '') + (n ? '<div class="cpx">' + cpExtras(cp, paths) + '</div>' : '')
545
553
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
546
554
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
547
555
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
556
+ (rows ? '' : doneRow) +
548
557
  '<dt>Stopped</dt>' + dd(cp.stoppedAt) +
549
558
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
550
559
  (showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · ' + (cp.generator.by === 'heuristic' ? '' : 'by ') + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) : '') + '</div>' : '') +
@@ -1349,11 +1358,30 @@ function wtRestoreView(node) {
1349
1358
  wtStuck(node.querySelector('.wt-scroll'))
1350
1359
  }
1351
1360
  // The reviewed marks and the count, in place (no redraw).
1361
+ // the steps reviewed, as saved plus the marks still on their way
1362
+ function wtRevNow(s, data) {
1363
+ const rev = reviewedSet(s, data.fingerprint)
1364
+ for (const x of wtUi.pending) if (x.fp === data.fingerprint) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1365
+ return rev
1366
+ }
1367
+ // the slides as revNextState reads them: each step's id and number
1368
+ function wtDeckSteps(slides, data) {
1369
+ const by = new Map((data.steps || []).map((x) => [x.id, x]))
1370
+ return slides.map((x) => { const st = x.dataset.step && by.get(x.dataset.step); return { step: st ? { id: st.id, n: st.n } : null } })
1371
+ }
1372
+ // Mark reviewed & next / & step N / & finish, for the slide on screen and what's reviewed now
1373
+ function wtRevBtn(node, data, rev) {
1374
+ const deck = node.querySelector('#wtDeck'), rv = node.querySelector('#wtDrev'); if (!deck || !rv) return
1375
+ const slides = [...deck.children], rs = revNextState(wtDeckSteps(slides, data), Number(deck.dataset.cur) || 0, rev)
1376
+ rv.hidden = !rs.show
1377
+ const [l, sh] = revNextLabel(rs.last, rs.back)
1378
+ rv.setAttribute('aria-label', l); rv.dataset.tip = revNextTip(rs)
1379
+ const a1 = rv.querySelector('.wt-dl-l'), a2 = rv.querySelector('.wt-dl-s'); if (a1) a1.textContent = l; if (a2) a2.textContent = sh
1380
+ }
1352
1381
  function wtMarks(s) {
1353
1382
  const node = wtUi.node; if (!node || !s) return
1354
1383
  const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
1355
- const rev = reviewedSet(s, data.fingerprint)
1356
- for (const x of wtUi.pending) if (x.fp === data.fingerprint) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1384
+ const rev = wtRevNow(s, data)
1357
1385
  for (const a of node.querySelectorAll('article.wt-st')) {
1358
1386
  const on = rev.has(a.dataset.step)
1359
1387
  a.classList.toggle('done', on)
@@ -1363,6 +1391,8 @@ function wtMarks(s) {
1363
1391
  for (const d of node.querySelectorAll('.wt-dot[data-step]')) { const on = rev.has(d.dataset.step); if (d.classList.contains('done') !== on) { d.classList.toggle('done', on); d.setAttribute('aria-label', d.getAttribute('aria-label').replace(/, reviewed$/, '') + (on ? ', reviewed' : '')) } } // 0.9.18 a ✓ on its dot
1364
1392
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1365
1393
  const p = node.querySelector('#wtProg'); if (p) p.textContent = (data.steps || []).filter((x) => rev.has(x.id)).length + ' of ' + (data.steps || []).length + ' reviewed'
1394
+ const all = node.querySelector('#wtAllDone'); if (all) all.hidden = !((data.steps || []).length && data.steps.every((x) => rev.has(x.id)))
1395
+ wtRevBtn(node, data, rev)
1366
1396
  }
1367
1397
  async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1368
1398
  const s = wtSession(sid)
@@ -1388,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1388
1418
  function wtRevNext() {
1389
1419
  const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
1390
1420
  const slides = [...deck.children], cur = wtDeckCur()
1391
- const st = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), cur)
1421
+ const sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1422
+ if (!s || !data) return
1423
+ const rev = wtRevNow(s, data)
1424
+ const st = revNextState(wtDeckSteps(slides, data), cur, rev)
1392
1425
  if (!st.show) return void wtSlideGo(cur + 1)
1393
- const step = slides[cur].dataset.step, sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1394
- const done = s && data && (reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && x.on)) && !wtUi.pending.some((x) => data && x.fp === data.fingerprint && x.step === step && !x.on)
1395
- if (!done) wtReviewed(sid, step, { force: true, advance: false })
1426
+ const step = slides[cur].dataset.step
1427
+ if (!rev.has(step)) wtReviewed(sid, step, { force: true, advance: false })
1396
1428
  wtSlideGo(st.to)
1397
1429
  }
1398
1430
  function wtGo(sid, step, { group = null, file = null } = {}) {
@@ -1443,8 +1475,7 @@ function wtSlideGo(i) {
1443
1475
  if (nav) {
1444
1476
  nav.querySelector('.wt-dprev').disabled = i === 0
1445
1477
  nav.querySelector('.wt-dnext').disabled = i === n - 1
1446
- const rv = nav.querySelector('#wtDrev'), rs = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), i)
1447
- if (rv) { rv.hidden = !rs.show; const [l, sh] = revNextLabel(rs.last); rv.setAttribute('aria-label', l); rv.dataset.tip = rs.last ? REVFINISH_TIP : REVNEXT_TIP; const a1 = rv.querySelector('.wt-dl-l'), a2 = rv.querySelector('.wt-dl-s'); if (a1) a1.textContent = l; if (a2) a2.textContent = sh }
1478
+ const s = wtSession(wtUi.sid); if (s && data.fingerprint) wtRevBtn(node, data, wtRevNow(s, data))
1448
1479
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1449
1480
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1450
1481
  ;[...nav.querySelectorAll('.wt-dot')].forEach((d, j) => { d.classList.toggle('cur', j === i); if (j === i) d.setAttribute('aria-current', 'step'); else d.removeAttribute('aria-current') })
@@ -1507,11 +1538,26 @@ function wtExport(kind) {
1507
1538
  if (!s || !data || data.status !== 'ok') return
1508
1539
  const title = s.title || 'Session'
1509
1540
  if (kind === 'md') return copy(toMarkdown(data, { title })).then((ok) => toast(ok ? 'Copied as Markdown · paste it into the PR' : 'Couldn’t copy', 3000))
1510
- const html = toHtmlDoc(data, { title, repo: [s.repoLabel || s.repoName || '', s.repo && s.repo.branch].filter(Boolean).join(' · '), when: data.generatedAt ? clock(data.generatedAt) : '' })
1541
+ return saveHtmlFile(walkthroughExportHtml(data, { ...s, title }), exportName(title)) // 2026-11-06 the same file as leftoff export walkthrough
1542
+ }
1543
+ // 2026-11-06 Save as HTML (digest, decision log, walkthrough): one self-contained file. On a phone the
1544
+ // share sheet (Save to Files, AirDrop, Mail …) where it takes files; else a download. Nothing is uploaded.
1545
+ async function saveHtmlFile(html, name) {
1546
+ const coarse = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
1547
+ if (coarse && typeof File === 'function' && navigator.canShare && navigator.share) {
1548
+ try {
1549
+ const f = new File([html], name, { type: 'text/html' })
1550
+ if (navigator.canShare({ files: [f] })) { await navigator.share({ files: [f], title: name.replace(/\.html$/, '') }); return true }
1551
+ } catch (e) { if (e && e.name === 'AbortError') return false } // closed the sheet: nothing else
1552
+ }
1511
1553
  const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
1512
- const a = document.createElement('a'); a.href = url; a.download = exportName(title); document.body.appendChild(a); a.click(); a.remove()
1554
+ const x = document.createElement('a'); x.href = url; x.download = name; document.body.appendChild(x); x.click(); x.remove()
1513
1555
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1556
+ toast('Saved ' + name + ' · one file, opens offline', 3000)
1557
+ return true
1514
1558
  }
1559
+ const SAVE_HTML_TIP = 'Save as HTML · one file with nothing linked out: opens offline, on a phone, and prints'
1560
+ const saveHtmlBtn = (action, cls = '') => '<button type="button" class="btn save-html' + (cls ? ' ' + cls : '') + '" data-action="' + action + '" data-tip="' + esc(SAVE_HTML_TIP) + '" aria-label="Save as HTML">' + icon('download') + '<span class="save-html-l">Save as HTML</span></button>'
1515
1561
  // ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
1516
1562
  // Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
1517
1563
  // number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
@@ -1645,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
1645
1691
  // Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
1646
1692
  // (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
1647
1693
  function wtSpots(data) { return data && data.status === 'ok' ? spotsFor(data, readingOrder(data.steps, data)) : [] }
1694
+ // 2026-11-06 from where you're reading (ui/walk-spots.js spotFrom): the slide on screen, or on the
1695
+ // long page the step you're at; -1 the overview, Infinity Safe to skip
1696
+ function wtSpotAt(data) {
1697
+ const order = readingOrder(data.steps, data).map((x) => x.id)
1698
+ const rank = (id) => order.indexOf(id)
1699
+ const deck = wtUi.node && wtUi.node.querySelector('#wtDeck')
1700
+ let at = -1
1701
+ if (deck) { const sl = deck.children[Number(deck.dataset.cur) || 0]; at = !sl || sl.dataset.slide === '0' ? -1 : sl.dataset.step ? rank(sl.dataset.step) : Infinity }
1702
+ else { const c = wtUi.cur.get(wtKey(wtUi.sid)); at = c ? rank(c) : -1 }
1703
+ return { rank, at }
1704
+ }
1648
1705
  function wtSpotGo(dir = 1) {
1649
1706
  const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1650
1707
  if (!data || data.status !== 'ok' || !wtUi.node) return
1651
1708
  const spots = wtSpots(data)
1652
1709
  if (!spots.length) return
1653
1710
  const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1654
- const i = nextSpotIndex(spots.length, cur, dir)
1711
+ const { rank, at } = wtSpotAt(data)
1712
+ const i = spotFrom(spots, rank, at, cur, dir)
1655
1713
  wtUi.spot.set(data.fingerprint, i)
1656
1714
  const sp = spots[i]
1715
+ const row = wtReveal('s:' + sp.step + ':' + sp.snip, wtSpotRi(data, sp))
1716
+ wtSpotPaint()
1717
+ if (!row) return
1718
+ const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1719
+ const b = wtUi.node.querySelector('.wt-spot'); if (b) b.setAttribute('aria-label', 'Next spot to look at, ' + spotCountWords(spots.length, i))
1720
+ }
1721
+ // a spot -> its row's index in its snippet's drawn diff
1722
+ function wtSpotRi(data, sp) {
1657
1723
  const st = (data.steps || []).find((x) => x.id === sp.step)
1658
1724
  const sn = st && st.snippets[sp.snip]
1659
1725
  const rows = sn ? rowsOf(sn.patch) : []
1660
- const ri = rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1661
- for (const x of wtUi.node.querySelectorAll('.wt-spot-on')) x.classList.remove('wt-spot-on')
1662
- for (const x of wtUi.node.querySelectorAll('.wt-spot-tag')) x.remove()
1663
- const row = wtReveal('s:' + sp.step + ':' + sp.snip, ri)
1664
- // in the split drawing a removed line is the left half, an added one the right
1665
- const gd = row && row.closest('.wt-gd')
1666
- const cands = gd ? [...gd.querySelectorAll('[data-ri="' + ri + '"]')] : []
1667
- const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0] || null
1668
- if (at) {
1669
- at.classList.add('wt-spot-on')
1670
- const tag = document.createElement('span')
1671
- tag.className = 'wt-spot-tag'
1672
- tag.setAttribute('role', 'note')
1673
- tag.innerHTML = icon('crosshair') + '<span></span>'
1674
- tag.lastChild.textContent = sp.why || 'Worth a look'
1675
- at.appendChild(tag)
1676
- }
1677
- const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1678
- const b = wtUi.node.querySelector('.wt-spot'); if (b) b.setAttribute('aria-label', 'Next spot to look at, ' + spotCountWords(spots.length, i))
1726
+ return rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1727
+ }
1728
+ // 2026-11-06 (Joel: "it's the only time you see the special chip, that should probably be visible
1729
+ // all the time"): every spot's chip sits at its line whenever its diff is drawn, the one Next spot
1730
+ // is on marked; each chip explains itself in a tip (hover, focus, or a tap on a phone: ui/tips.js)
1731
+ function wtSpotPaint() {
1732
+ const node = wtUi.node, sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1733
+ if (!node || !data || data.status !== 'ok' || wtUi.view !== 'story') return
1734
+ const spots = wtSpots(data)
1735
+ const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1736
+ spots.forEach((sp, i) => {
1737
+ const gd = node.querySelector('.wt-gd[data-wtk="' + CSS.escape('s:' + sp.step + ':' + sp.snip) + '"]')
1738
+ if (!gd) return
1739
+ const ri = wtSpotRi(data, sp)
1740
+ const cands = [...gd.querySelectorAll('[data-ri="' + ri + '"]')]
1741
+ // in the split drawing a removed line is the left half, an added one the right
1742
+ const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0]
1743
+ if (!at) return
1744
+ let tag = at.querySelector(':scope > .wt-spot-tag')
1745
+ if (!tag) {
1746
+ const id = 'wt-spot-tip-' + i
1747
+ tag = document.createElement('span')
1748
+ tag.className = 'wt-spot-tag'
1749
+ tag.innerHTML = '<button type="button" class="tip-btn wt-spot-chip" aria-expanded="false" aria-controls="' + id + '" aria-describedby="' + id + '">' + icon('crosshair') + '<span></span></button><span class="tip" role="tooltip" id="' + id + '" hidden></span>'
1750
+ tag.querySelector('.wt-spot-chip span').textContent = sp.why || 'Worth a look'
1751
+ tag.querySelector('.wt-spot-chip').setAttribute('aria-label', 'Spot ' + (i + 1) + ' of ' + spots.length + ': ' + (sp.why || 'worth a look') + '. Why it is a spot')
1752
+ tag.querySelector('.tip').textContent = spotTip(sp)
1753
+ at.appendChild(tag)
1754
+ }
1755
+ const on = i === cur
1756
+ at.classList.toggle('wt-spot-on', on)
1757
+ tag.classList.toggle('cur', on)
1758
+ })
1679
1759
  }
1680
1760
  // Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
1681
1761
  // only on a screen 1000px or wider, else unified (the toggle is hidden there)
@@ -1683,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
1683
1763
  function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
1684
1764
  function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
1685
1765
  if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
1686
- function wtHydrate(node, data) { return hydrateWalkthrough(node, data, { renderDiff, renderSplit, split: wtSplitOn(), esc, icon }) }
1766
+ function wtHydrate(node, data) { const r = hydrateWalkthrough(node, data, { renderDiff, renderSplit, split: wtSplitOn(), esc, icon }); if (node === wtUi.node) wtSpotPaint(); return r }
1687
1767
  // the file list folds to a rail ([ or its button) and its edge drags; both kept per device
1688
1768
  function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
1689
1769
  function wtListUp() {
@@ -4995,6 +5075,7 @@ document.addEventListener('click', async (ev) => {
4995
5075
  if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
4996
5076
  if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4997
5077
  if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
5078
+ if (a === 'dec-save-html') { const v = decisionsUi.view; if (v) saveHtmlFile(decisionsExportHtml(v, { all: !!decisionsUi.all }), exportFileName('decisions', (v.scope && (v.scope.name || v.scope.label)) || '')); return } // 2026-11-06
4998
5079
  if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4999
5080
  if (a === 'dec-show') {
5000
5081
  const el = document.getElementById('dec-' + btn.dataset.id)
@@ -5255,9 +5336,6 @@ async function viewTimeline() {
5255
5336
  $app.innerHTML = html
5256
5337
  }
5257
5338
 
5258
- // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
5259
- // links there and Start here gets that session's Go / Resume.
5260
- const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
5261
5339
  function briefAction(id, sessions, viewer) {
5262
5340
  const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
5263
5341
  const s = sessions && sessions.get(id)
@@ -5269,75 +5347,10 @@ function briefAction(id, sessions, viewer) {
5269
5347
  return b || open
5270
5348
  }
5271
5349
 
5350
+ // 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
5272
5351
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
5273
- const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
5274
- // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
5275
- const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').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)
5276
- const out = []
5277
- let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
5278
- const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
5279
- const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
5280
- const href = (id) => '#/s/' + encodeURIComponent(id)
5281
- // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
5282
- // other "## " headings (Lists, Top of mind) are whole-folder sections.
5283
- // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
5284
- const openSec = (h) => {
5285
- if (inSec) out.push('</section>')
5286
- inSec = true
5287
- if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
5288
- else if (spaces.includes(h)) {
5289
- out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
5290
- '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
5291
- } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
5292
- }
5293
- for (const raw of body.split('\n')) {
5294
- const mid = raw.match(MD_SID)
5295
- const id = mid ? mid[1] : null
5296
- const line = mid ? raw.slice(0, mid.index) : raw
5297
- // 0.9.8: Start here: "1. <step> — **Title** (repo)"
5298
- const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
5299
- if (st) {
5300
- openList('ol', 'start')
5301
- const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
5302
- 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>' +
5303
- (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
5304
- continue
5305
- }
5306
- if (/^- /.test(line)) {
5307
- openList('ul')
5308
- // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
5309
- const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
5310
- const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
5311
- // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
5312
- // session: the title links to it, the sentence under it, the tags quiet.
5313
- const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
5314
- // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
5315
- if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div><div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>'); continue }
5316
- if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
5317
- // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
5318
- const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
5319
- out.push('<li' + attrs + '>' + li); continue
5320
- }
5321
- if (/^\s{2,}\S/.test(line) && inList) {
5322
- // "Stopped at: … Next: …" is two lines, each with its label.
5323
- const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
5324
- 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>' : ''))
5325
- else out.push('<span class="sub">' + inline(t) + '</span>')
5326
- continue
5327
- }
5328
- closeList()
5329
- if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
5330
- if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
5331
- else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
5332
- // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
5333
- 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 }
5334
- else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
5335
- // 0.9.8: the Yesterday / Today opener, above the first section
5336
- else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
5337
- }
5338
- closeList()
5339
- if (inSec) out.push('</section>')
5340
- return out.join('\n')
5352
+ return digestBodyHtml(md, { spaces, esc, action: (id) => briefAction(id, sessions, viewer),
5353
+ standup: (h) => '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button>' })
5341
5354
  }
5342
5355
 
5343
5356
  // "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
@@ -5446,6 +5459,7 @@ function repaintCalendar() {
5446
5459
  // repo chips, which only count the picked space.
5447
5460
  const digestUi = { standups: {}, date: null, open: null }
5448
5461
  async function viewDigest(date, machine) {
5462
+ digestUi.md = null
5449
5463
  const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
5450
5464
  const byDay = new Map()
5451
5465
  for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
@@ -5474,9 +5488,12 @@ async function viewDigest(date, machine) {
5474
5488
  if (pick) {
5475
5489
  try {
5476
5490
  // 0.9.8: the sessions too, for Start here's Go / Resume
5477
- const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
5491
+ const ver = ((state.route && state.route.params) || new URLSearchParams()).get('v') // 2026-11-06: an earlier version (ui/digest-versions.js)
5492
+ const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ver ? '?version=' + encodeURIComponent(ver) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
5493
+ digestUi.shown = d
5478
5494
  if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
5479
5495
  const fm = d.meta || {}
5496
+ digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
5480
5497
  const madeAt = fm.generated_at
5481
5498
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
5482
5499
  const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
@@ -5484,12 +5501,13 @@ async function viewDigest(date, machine) {
5484
5501
  const others = (d.others || []).filter((x) => x.machine || x.shared)
5485
5502
  const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
5486
5503
  const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
5487
- html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
5504
+ html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + '<div class="digest-save">' + saveHtmlBtn('digest-save-html') + '</div>' + also + unnamed +
5488
5505
  '<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
5489
5506
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
5490
5507
  } else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
5491
5508
  else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
5492
5509
  $app.innerHTML = html + '</div>'
5510
+ if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
5493
5511
  // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
5494
5512
  const art = $app.querySelector('article.md')
5495
5513
  const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
@@ -5515,7 +5533,7 @@ function paintDigestFilter() {
5515
5533
  const spaces = state.digestSpaces || []
5516
5534
  const sp = state.digestSpace
5517
5535
  const secs = [...document.querySelectorAll('.md section')]
5518
- for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
5536
+ for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !sec.classList.contains('done-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
5519
5537
  const tabsBox = document.getElementById('digestSpaces')
5520
5538
  if (tabsBox) {
5521
5539
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -6399,7 +6417,7 @@ async function viewSettings() {
6399
6417
  '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
6400
6418
  '<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
6401
6419
  '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
6402
- digest: digestSettings(c),
6420
+ digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
6403
6421
  spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
6404
6422
  sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
6405
6423
  agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
@@ -6446,6 +6464,7 @@ async function viewSettings() {
6446
6464
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
6447
6465
  '<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
6448
6466
  showSettingsSection(r)
6467
+ api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
6449
6468
  approvedUi.info = starts; approvedUi.err = ''
6450
6469
  fillApproved($app, starts) // approved folders: paths as text
6451
6470
  const ap = document.getElementById('f-start-approve')
@@ -8539,7 +8558,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
8539
8558
  tip.classList.add('on')
8540
8559
  }, delay)
8541
8560
  }
8542
- const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
8561
+ const tipped = (e) => e.target.closest && e.target.closest('.save-html[data-tip], #tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
8543
8562
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
8544
8563
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
8545
8564
  document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
@@ -9249,6 +9268,7 @@ document.addEventListener('click', async (ev) => {
9249
9268
  if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
9250
9269
  if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
9251
9270
  if (action === 'wt-export-html') return wtExport('html')
9271
+ if (action === 'digest-save-html') { if (digestUi.md) saveHtmlFile(digestExportHtml(digestUi.md, { date: digestUi.mdDate }), exportFileName('digest', digestUi.mdDate || '')); return } // 2026-11-06
9252
9272
  if (action === 'wt-export-md') return wtExport('md')
9253
9273
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
9254
9274
  if (action === 'copy-fact') {
@@ -9262,8 +9282,9 @@ document.addEventListener('click', async (ev) => {
9262
9282
  if (action === 'copy-cmd') {
9263
9283
  const ok = await copy(btn.dataset.cmd)
9264
9284
  if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
9285
+ if (!btn._label) btn._label = btn.getAttribute('aria-label') || 'Copy' // 2026-11-06: each button keeps its own label (Live resume, the MCP setup lines)
9265
9286
  clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
9266
- btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
9287
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', btn._label); btn.dataset.tip = 'Copy' }, 1500)
9267
9288
  }
9268
9289
  return
9269
9290
  }
@@ -9883,6 +9904,7 @@ document.addEventListener('click', async (ev) => {
9883
9904
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
9884
9905
  if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
9885
9906
  if (action === 'doc-close') return closeDoc()
9907
+ if (action === 'hp-mode' || action === 'hp-show' || action === 'hp-allow' || action === 'hp-block' || action === 'hp-reload') return hpAction(action, btn)
9886
9908
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
9887
9909
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
9888
9910
  if (action === 'need-restore' || action === 'need-restore-all') {
@@ -10181,7 +10203,7 @@ async function todoDoneChange(cb) {
10181
10203
 
10182
10204
  // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
10183
10205
  // that owns it (through another Mac or the relay from a phone). Markdown is
10184
- // rendered from escaped text; HTML files show as source; anything else downloads.
10206
+ // rendered from escaped text; HTML files open on Preview (sandboxed) or Code; anything else downloads.
10185
10207
  const docUi = { r: null, name: '', printing: null }
10186
10208
  function docViewerEl() {
10187
10209
  let el = document.getElementById('docViewer')
@@ -10201,7 +10223,88 @@ function docViewerEl() {
10201
10223
  document.body.appendChild(el)
10202
10224
  return el
10203
10225
  }
10204
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
10226
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; el.querySelector('.dv-body').classList.remove('dv-hpv'); hpUi.r = null; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
10227
+ function docBodyLoading(el) { const b = el.querySelector('.dv-body'); b.classList.remove('dv-hpv'); hpUi.r = null; b.innerHTML = '<div class="empty">Opening…</div>' }
10228
+ // 2026-11-06 An HTML file: Preview (ui/html-preview.js, a sandboxed frame; remote
10229
+ // resources blocked until Allow, for this view only) or Code, remembered per device.
10230
+ const hpUi = { r: null, code: '', mode: 'preview', remote: false, open: false, refs: [] }
10231
+ function hpViewPref() { try { return localStorage.getItem(HP_VIEW_KEY) === 'code' ? 'code' : 'preview' } catch { return 'preview' } }
10232
+ function mountHtmlPreview(body, r, code, { line = null } = {}) {
10233
+ if (!isHtmlDoc(r)) return false
10234
+ Object.assign(hpUi, { r, code, mode: line ? 'code' : hpViewPref(), remote: false, open: false, refs: remoteRefs(r.text) })
10235
+ body.innerHTML = '<div class="hp-bar"></div><div class="hp-main"></div>'
10236
+ paintHpBar(body); paintHpMain(body)
10237
+ return true
10238
+ }
10239
+ function paintHpBar(body) { const bar = body.querySelector('.hp-bar'); if (bar) bar.innerHTML = previewBarHtml({ ...hpUi, icon }) }
10240
+ function paintHpMain(body) {
10241
+ const main = body.querySelector('.hp-main')
10242
+ if (!main || !hpUi.r) return
10243
+ const pv = hpUi.mode === 'preview'
10244
+ body.classList.toggle('dv-hpv', pv)
10245
+ if (!pv) { main.innerHTML = hpUi.code; return }
10246
+ main.textContent = ''
10247
+ const f = document.createElement('iframe')
10248
+ f.className = 'hp-frame'
10249
+ f.setAttribute('sandbox', 'allow-scripts')
10250
+ f.setAttribute('referrerpolicy', 'no-referrer')
10251
+ f.setAttribute('aria-label', 'Preview of ' + (hpUi.r.name || 'the page'))
10252
+ f._hpDoc = previewDoc(hpUi.r.text, { remote: hpUi.remote })
10253
+ f._hpPing = 0
10254
+ // once the page is in, every load is checked: only the page we wrote answers the ping
10255
+ // (ui/html-preview.js GUARD), so silence means it went somewhere else
10256
+ f.addEventListener('load', () => {
10257
+ if (f._hpDoc != null || !f.isConnected || !f.contentWindow) return
10258
+ const n = ++f._hpPing
10259
+ f.contentWindow.postMessage({ t: 'lo-hp-ping', n }, '*')
10260
+ clearTimeout(f._hpWait)
10261
+ f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1000)
10262
+ })
10263
+ f.src = PREVIEW_PAGE + (hpUi.remote ? '?remote=1' : '')
10264
+ main.appendChild(f)
10265
+ }
10266
+ // The page sent itself somewhere (CSP can't stop that): the frame goes, and the bar says so.
10267
+ function hpStopped(f) {
10268
+ if (!f || !f.isConnected) return
10269
+ clearTimeout(f._hpWait)
10270
+ const main = f.closest('.hp-main')
10271
+ f.remove()
10272
+ if (main) main.innerHTML = '<div class="empty fv-state hp-stopped"><b>Preview stopped</b><span>The page tried to open another address, so it was closed. Code shows what it does.</span><button type="button" class="btn sm" data-action="hp-reload">Load it again</button></div>'
10273
+ }
10274
+ function hpAction(action, btn) {
10275
+ const body = document.querySelector('#docViewer .dv-body')
10276
+ if (!body || !hpUi.r) return
10277
+ if (action === 'hp-mode') {
10278
+ const m = btn.dataset.mode === 'code' ? 'code' : 'preview'
10279
+ if (m === hpUi.mode) return
10280
+ hpUi.mode = m
10281
+ try { localStorage.setItem(HP_VIEW_KEY, m) } catch {}
10282
+ paintHpBar(body); paintHpMain(body); body.scrollTop = 0
10283
+ } else if (action === 'hp-show') { hpUi.open = !hpUi.open; paintHpBar(body) }
10284
+ else if (action === 'hp-reload') paintHpMain(body)
10285
+ else if (action === 'hp-allow' || action === 'hp-block') { hpUi.remote = action === 'hp-allow'; paintHpBar(body); paintHpMain(body) }
10286
+ const again = body.querySelector('[data-action="' + action + '"]') || body.querySelector('[data-action="' + (action === 'hp-allow' ? 'hp-block' : action === 'hp-block' ? 'hp-allow' : action) + '"]')
10287
+ if (again && action !== 'hp-mode') again.focus({ preventScroll: true })
10288
+ }
10289
+ // The sandbox asks for its page once (a frame that navigated away gets nothing), says where a
10290
+ // clicked link goes (it doesn't open), and passes Esc on, since keys in the frame stay there.
10291
+ window.addEventListener('message', (ev) => {
10292
+ const d = ev.data
10293
+ if (!d || typeof d !== 'object' || typeof d.t !== 'string' || !d.t.startsWith('lo-hp-')) return
10294
+ const f = [...document.querySelectorAll('#docViewer .hp-frame')].find((x) => x.contentWindow === ev.source)
10295
+ if (!f) return
10296
+ if (d.t === 'lo-hp-ready' && f._hpDoc != null) {
10297
+ const html = f._hpDoc; f._hpDoc = null
10298
+ ev.source.postMessage({ t: 'lo-hp-html', html }, '*')
10299
+ const n = ++f._hpPing // the write itself may not fire a load: check once now too
10300
+ ev.source.postMessage({ t: 'lo-hp-ping', n }, '*')
10301
+ f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1500)
10302
+ }
10303
+ else if (d.t === 'lo-hp-pong') { if (d.n === f._hpPing) { f._hpPong = d.n; clearTimeout(f._hpWait) } }
10304
+ else if (d.t === 'lo-hp-left') hpStopped(f)
10305
+ else if (d.t === 'lo-hp-link' && typeof d.href === 'string') toast('Links don’t open from a preview: ' + d.href.slice(0, 160), 5000)
10306
+ else if (d.t === 'lo-hp-esc') closeDoc()
10307
+ })
10205
10308
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
10206
10309
  async function openDoc(sessionId, { path, plan } = {}) {
10207
10310
  if (!sessionId || (!path && !plan)) return
@@ -10210,7 +10313,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
10210
10313
  const name = plan ? 'Plan' : String(path).split('/').pop()
10211
10314
  el.querySelector('.dv-title').textContent = name
10212
10315
  el.querySelector('.dv-sub').textContent = ''
10213
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10316
+ docBodyLoading(el)
10214
10317
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10215
10318
  docUi.r = null; docUi.name = name
10216
10319
  el.hidden = false
@@ -10222,6 +10325,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
10222
10325
  el.querySelector('.dv-title').textContent = r.name || name
10223
10326
  el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
10224
10327
  el.querySelector('.dv-body').innerHTML = docViewerBody(r)
10328
+ mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
10225
10329
  el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
10226
10330
  docUi.r = r; docUi.name = name
10227
10331
  const items = docMenuItems(r)
@@ -10246,7 +10350,7 @@ async function openToolOutput(sessionId, toolId, label) {
10246
10350
  const name = label || 'Tool output'
10247
10351
  el.querySelector('.dv-title').textContent = name
10248
10352
  el.querySelector('.dv-sub').textContent = ''
10249
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10353
+ docBodyLoading(el)
10250
10354
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10251
10355
  docUi.r = null; docUi.name = name
10252
10356
  el.hidden = false
@@ -10327,7 +10431,7 @@ async function loadRefFile() {
10327
10431
  el.querySelector('.dv-title').textContent = name
10328
10432
  el.querySelector('.dv-sub').textContent = ''
10329
10433
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
10330
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10434
+ docBodyLoading(el)
10331
10435
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10332
10436
  docUi.r = null; docUi.name = name; refUi.r = null
10333
10437
  const wasHidden = el.hidden
@@ -10343,7 +10447,9 @@ async function loadRefFile() {
10343
10447
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
10344
10448
  el.querySelector('.dv-sub').textContent = [r.size ? refSize(r.size) : '', r.lang ? r.lang.toUpperCase() : '', r.peer ? 'on ' + r.peer : '', refUi.line && (r.type === 'code' || r.type === 'text') ? 'line ' + refUi.line : ''].filter(Boolean).join(' · ')
10345
10449
  const body = el.querySelector('.dv-body')
10346
- body.innerHTML = refBody(r, { line: refUi.line })
10450
+ const code = refBody(r, { line: refUi.line })
10451
+ body.innerHTML = code
10452
+ mountHtmlPreview(body, r, code, { line: refUi.line })
10347
10453
  el.querySelector('.dv-ptitle').textContent = r.name || name
10348
10454
  if (r.type === 'pdf' && r.base64) {
10349
10455
  const b64 = r.base64