@leftoff/cli 0.9.18-beta.12 → 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,8 +1,10 @@
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
- import { fullViewportFit, isTypingTarget, outOfView, sheetAside, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_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
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'
7
9
  import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
8
10
  import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
@@ -14,7 +16,7 @@ import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
14
16
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
15
17
  import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
16
18
  import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
17
- import { canStartPull, pullStep, pullRelease } from './pull.js'
19
+ import { canStartPull, pullStep, pullRelease, onPullHead } from './pull.js'
18
20
  import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
19
21
  import { loadSessionOrWait, loadingFromHtml, missingSessionHtml, missingKind, startedHref, startingHtml, STARTING_MS } from './session-missing.js' // 0.9.12: a session this Mac can't load
20
22
  import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
@@ -23,6 +25,7 @@ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepF
23
25
  import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBangStore, bangRowHtml, cmdLabel } from './bang.js' // 0.9.15 "!" commands
24
26
  import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
25
27
  import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
28
+ import { subPillHtml, subStripHtml, subSig } from './subagents-view.js' // 2026-11-06 "2 subagents working"
26
29
  import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
27
30
  import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
28
31
  import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
@@ -50,22 +53,23 @@ import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel
50
53
  import { cutTitle } from './session-title.js'
51
54
  import { sessionTitle, sessionWhere, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
52
55
  import { statusView, elapsedText } from './live-status.js'
53
- import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
56
+ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS, resumedAfter, catchUp, viewSignature } from './live-wake.js' // 0.9.8 live after sleep; 2026-11-06 catch up on return
54
57
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
55
58
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
56
59
  import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
57
60
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
58
61
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
59
62
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
60
- 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
61
64
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
62
- 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
63
66
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
64
67
  import { lastReply, ghostShows } from './ghost.js'
65
68
  import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
66
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
67
70
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
68
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'
69
73
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
70
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
71
75
  import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
@@ -78,7 +82,9 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
78
82
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
79
83
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
80
84
  import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
81
- 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)
82
88
  import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
83
89
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
84
90
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
@@ -468,7 +474,7 @@ function copiedMark(btn) {
468
474
  btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
469
475
  }
470
476
  // 0.9.13: 'background' reads "2 tasks running", its tip naming them (ui/bgtasks.js)
471
- const pill = (status, s) => status === 'background' && s ? bgPillHtml(s, esc) : '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
477
+ const pill = (status, s) => s && (status === 'active' || status === 'background') && s.subagents && s.subagents.n > 0 ? subPillHtml(s, esc) : status === 'background' && s ? bgPillHtml(s, esc) : '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
472
478
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
473
479
  // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
474
480
  const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an asleep Mac's channel isn't listening
@@ -539,11 +545,15 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
539
545
  }
540
546
  // 0.9.15: what the session is for and how far along, above its latest step
541
547
  const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
542
- 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>'
543
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>' : '')
544
553
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
545
554
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
546
555
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
556
+ (rows ? '' : doneRow) +
547
557
  '<dt>Stopped</dt>' + dd(cp.stoppedAt) +
548
558
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
549
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>' : '') +
@@ -1348,11 +1358,30 @@ function wtRestoreView(node) {
1348
1358
  wtStuck(node.querySelector('.wt-scroll'))
1349
1359
  }
1350
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
+ }
1351
1381
  function wtMarks(s) {
1352
1382
  const node = wtUi.node; if (!node || !s) return
1353
1383
  const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
1354
- const rev = reviewedSet(s, data.fingerprint)
1355
- 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)
1356
1385
  for (const a of node.querySelectorAll('article.wt-st')) {
1357
1386
  const on = rev.has(a.dataset.step)
1358
1387
  a.classList.toggle('done', on)
@@ -1362,6 +1391,8 @@ function wtMarks(s) {
1362
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
1363
1392
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1364
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)
1365
1396
  }
1366
1397
  async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1367
1398
  const s = wtSession(sid)
@@ -1387,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1387
1418
  function wtRevNext() {
1388
1419
  const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
1389
1420
  const slides = [...deck.children], cur = wtDeckCur()
1390
- 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)
1391
1425
  if (!st.show) return void wtSlideGo(cur + 1)
1392
- const step = slides[cur].dataset.step, sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1393
- 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)
1394
- 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 })
1395
1428
  wtSlideGo(st.to)
1396
1429
  }
1397
1430
  function wtGo(sid, step, { group = null, file = null } = {}) {
@@ -1442,8 +1475,7 @@ function wtSlideGo(i) {
1442
1475
  if (nav) {
1443
1476
  nav.querySelector('.wt-dprev').disabled = i === 0
1444
1477
  nav.querySelector('.wt-dnext').disabled = i === n - 1
1445
- const rv = nav.querySelector('#wtDrev'), rs = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), i)
1446
- 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))
1447
1479
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1448
1480
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1449
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') })
@@ -1506,11 +1538,26 @@ function wtExport(kind) {
1506
1538
  if (!s || !data || data.status !== 'ok') return
1507
1539
  const title = s.title || 'Session'
1508
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))
1509
- 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
+ }
1510
1553
  const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
1511
- 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()
1512
1555
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1556
+ toast('Saved ' + name + ' · one file, opens offline', 3000)
1557
+ return true
1513
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>'
1514
1561
  // ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
1515
1562
  // Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
1516
1563
  // number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
@@ -1644,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
1644
1691
  // Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
1645
1692
  // (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
1646
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
+ }
1647
1705
  function wtSpotGo(dir = 1) {
1648
1706
  const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1649
1707
  if (!data || data.status !== 'ok' || !wtUi.node) return
1650
1708
  const spots = wtSpots(data)
1651
1709
  if (!spots.length) return
1652
1710
  const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1653
- const i = nextSpotIndex(spots.length, cur, dir)
1711
+ const { rank, at } = wtSpotAt(data)
1712
+ const i = spotFrom(spots, rank, at, cur, dir)
1654
1713
  wtUi.spot.set(data.fingerprint, i)
1655
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) {
1656
1723
  const st = (data.steps || []).find((x) => x.id === sp.step)
1657
1724
  const sn = st && st.snippets[sp.snip]
1658
1725
  const rows = sn ? rowsOf(sn.patch) : []
1659
- const ri = rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1660
- for (const x of wtUi.node.querySelectorAll('.wt-spot-on')) x.classList.remove('wt-spot-on')
1661
- for (const x of wtUi.node.querySelectorAll('.wt-spot-tag')) x.remove()
1662
- const row = wtReveal('s:' + sp.step + ':' + sp.snip, ri)
1663
- // in the split drawing a removed line is the left half, an added one the right
1664
- const gd = row && row.closest('.wt-gd')
1665
- const cands = gd ? [...gd.querySelectorAll('[data-ri="' + ri + '"]')] : []
1666
- const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0] || null
1667
- if (at) {
1668
- at.classList.add('wt-spot-on')
1669
- const tag = document.createElement('span')
1670
- tag.className = 'wt-spot-tag'
1671
- tag.setAttribute('role', 'note')
1672
- tag.innerHTML = icon('crosshair') + '<span></span>'
1673
- tag.lastChild.textContent = sp.why || 'Worth a look'
1674
- at.appendChild(tag)
1675
- }
1676
- const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1677
- 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
+ })
1678
1759
  }
1679
1760
  // Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
1680
1761
  // only on a screen 1000px or wider, else unified (the toggle is hidden there)
@@ -1682,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
1682
1763
  function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
1683
1764
  function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
1684
1765
  if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
1685
- 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 }
1686
1767
  // the file list folds to a rail ([ or its button) and its edge drags; both kept per device
1687
1768
  function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
1688
1769
  function wtListUp() {
@@ -3542,7 +3623,16 @@ function setFullClass(on) {
3542
3623
  // and the composer stays just above the keyboard. When the keyboard closes the
3543
3624
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3544
3625
  // its pan behind after a send, which put full screen ~300px down the screen).
3545
- const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
3626
+ const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0, raf: 0, until: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
3627
+ // 2026-11-06 iOS 26's keyboard bar floats over the page (ui/viewport-fit.js ACCESSORY_PX): an iPhone or iPad
3628
+ // whose Safari has a CSS feature that arrived in 26 keeps room for it above the keyboard
3629
+ const KB_ACCESSORY = (() => {
3630
+ try {
3631
+ const ios = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
3632
+ const glass = !!(window.CSS && CSS.supports && (CSS.supports('anchor-name: --lf') || CSS.supports('animation-timeline: scroll()')))
3633
+ return keyboardAccessoryPx({ ios, glass })
3634
+ } catch { return 0 }
3635
+ })()
3546
3636
  // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3547
3637
  function fitSheetAside() {
3548
3638
  const html = document.documentElement, a = document.activeElement
@@ -3555,16 +3645,17 @@ function fitFullViewport() {
3555
3645
  const vv = window.visualViewport
3556
3646
  const html = document.documentElement, st = html.style
3557
3647
  fitSheetAside()
3558
- if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3648
+ if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); st.removeProperty('--lf-acc'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3559
3649
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3560
3650
  const strip = document.getElementById('tabStrip')
3561
3651
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3562
3652
  st.setProperty('--lf-bar', bar + 'px')
3563
3653
  const typing = isTypingTarget(document.activeElement)
3564
- const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
3565
- if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
3654
+ const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing, accessory: KB_ACCESSORY })
3655
+ if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-acc'); html.classList.remove('lf-kb'); return }
3566
3656
  st.setProperty('--lf-h', fit.h + 'px')
3567
3657
  st.setProperty('--lf-top', fit.top + 'px')
3658
+ st.setProperty('--lf-acc', (fit.pad || 0) + 'px') // room kept under the composer for a keyboard bar drawn over the page
3568
3659
  html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3569
3660
  // the keyboard's gone: the page under full screen back where it was before the text box took focus
3570
3661
  if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
@@ -3579,7 +3670,7 @@ function keepTypingInView() {
3579
3670
  if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3580
3671
  const box = el.closest('.live-box.full')
3581
3672
  if (!box) return
3582
- const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3673
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv, 1, parseFloat(html.style.getPropertyValue('--lf-acc')) || 0)
3583
3674
  if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3584
3675
  }
3585
3676
  // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
@@ -3587,7 +3678,21 @@ function keepTypingInView() {
3587
3678
  // come with no report of its own, and a passing bogus one is put right by the next look.
3588
3679
  function onViewportChange(ev) {
3589
3680
  fitFullViewport()
3590
- if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
3681
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) { settleWhileTyping(1); followFrames() }
3682
+ }
3683
+ // 2026-11-06: and every frame for FOLLOW_FRAMES_MS after the box takes focus or iOS reports a change,
3684
+ // so the keyboard's animation (and iOS's numbers settling a frame or two late) is followed as it goes
3685
+ function followFrames() {
3686
+ if (typeof requestAnimationFrame !== 'function') return
3687
+ lfKb.until = performance.now() + FOLLOW_FRAMES_MS
3688
+ if (lfKb.raf) return
3689
+ const frame = () => {
3690
+ lfKb.raf = 0
3691
+ if (!isTypingTarget(document.activeElement) || !document.documentElement.classList.contains('live-full')) return
3692
+ fitFullViewport()
3693
+ if (performance.now() < lfKb.until) lfKb.raf = requestAnimationFrame(frame)
3694
+ }
3695
+ lfKb.raf = requestAnimationFrame(frame)
3591
3696
  }
3592
3697
  function settleWhileTyping(from = 0) {
3593
3698
  clearTimeout(lfKb.settle)
@@ -3612,6 +3717,7 @@ document.addEventListener('focusin', (e) => {
3612
3717
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3613
3718
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3614
3719
  settleWhileTyping(0)
3720
+ followFrames()
3615
3721
  })
3616
3722
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3617
3723
  // goes down, whatever iOS reports or doesn't on the way.
@@ -3740,7 +3846,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
3740
3846
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
3741
3847
  earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: rows, openGroups: askUi.groups, earlier: (v && v.earlier) || 0, may, mode: s.headless ? s.headless.mode : null, acts: may && !docked && !ownerOffline(s) && !turnSpinning(s, now), retried: retryUi.done, retrying: retryUi.busy }) + '</ol>' +
3742
3848
  '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
3743
- liveJumpHtml() + '</div>' +
3849
+ liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
3744
3850
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
3745
3851
  (ownerOffline(s) ? '<p class="live-off" role="note">' + icon('wifi-off') + '<span>' + esc(awayNote(s)) + ' (' + esc(offlineLine(s)) + '). Sending and answering work again once it’s back.</span></p></section>' : // 0.9.14: no composer or Allow/Deny for an asleep Mac
3746
3852
  (strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
@@ -4247,15 +4353,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
4247
4353
  // Fetch the thread and diff it into the page, even while you type (busy()
4248
4354
  // holds full redraws back, never the thread). A turn that ended also redraws
4249
4355
  // the rest of the page (the pill stops pulsing) once you're not busy.
4250
- async function refreshLive(reason) {
4356
+ async function refreshLive(reason, { force = false } = {}) {
4251
4357
  const id = liveOnScreen()
4252
- if (!id || liveUi.polling) return false
4358
+ // 2026-11-06: a fetch that was in flight when the phone froze the page may never answer; coming
4359
+ // back (force) doesn't wait behind it, and its late answer is dropped (the newer one wins)
4360
+ if (!id || (liveUi.polling && !(force && Date.now() - (liveUi.triedAt || 0) > 1500))) return false
4361
+ const gen = liveUi.gen = (liveUi.gen || 0) + 1
4253
4362
  liveUi.polling = true; liveUi.triedAt = Date.now()
4254
4363
  let v
4255
- try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
4364
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { if (gen === liveUi.gen) liveUi.polling = false }
4365
+ if (gen !== liveUi.gen) return false
4256
4366
  if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
4257
4367
  const s = freshSession(state.sessions.find((x) => x.id === id))
4258
- const sig = liveSignature(v.messages)
4368
+ const sig = liveSignature(v.messages) + '#' + subSig(v.subagents) // 2026-11-06: a subagent's step changes the thread's line
4259
4369
  const before = liveUi.sig[id]
4260
4370
  liveUi.sig[id] = sig
4261
4371
  if (s && before !== sig) {
@@ -4265,6 +4375,8 @@ async function refreshLive(reason) {
4265
4375
  const tmp = document.createElement('div')
4266
4376
  tmp.innerHTML = liveBoxHtml(s, v)
4267
4377
  const fresh = tmp.querySelector('.live-thread')
4378
+ const slot = box.querySelector('.lv-subs-slot'), freshSlot = tmp.querySelector('.lv-subs-slot') // 2026-11-06: subagents working, outside the kept thread
4379
+ if (slot && freshSlot) slot.innerHTML = freshSlot.innerHTML
4268
4380
  if (fresh) {
4269
4381
  const next = cur.nextSibling
4270
4382
  liveKeeper.before(box)
@@ -4284,8 +4396,13 @@ async function refreshLive(reason) {
4284
4396
  // The safety net, and the stream's watchdog, on one slow timer.
4285
4397
  const streamDog = createWatchdog()
4286
4398
  const wakeGate = createWake()
4399
+ let tickAt = Date.now() // 2026-11-06: the timer's own clock, to notice a frozen page (live-wake.js resumedAfter)
4287
4400
  setInterval(() => {
4401
+ const was = tickAt; tickAt = Date.now()
4288
4402
  if (document.visibilityState === 'hidden') return
4403
+ const frozen = resumedAfter(was, tickAt)
4404
+ if (frozen) onWake({ type: 'resume', away: frozen })
4405
+ if (renderDue && !busy()) { renderDue = false; render() } // a redraw a wake-up couldn't make while you typed
4289
4406
  const id = liveOnScreen()
4290
4407
  if (shouldPoll({ visible: true, onLive: !!id, pending: !!(id && liveUi.pending[id]) }) && Date.now() - Math.max(liveUi.okAt[id] || 0, liveUi.triedAt || 0) >= POLL_MS - 250) refreshLive('poll')
4291
4408
  if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
@@ -4293,27 +4410,66 @@ setInterval(() => {
4293
4410
  // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
4294
4411
  // bfcache restore, the network returns): fetch what's on screen at once, and
4295
4412
  // open a new stream if the old one missed its pings.
4296
- function refreshView(reason) {
4297
- if (liveOnScreen()) refreshLive(reason)
4298
- if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
4299
- refreshHealth()
4413
+ let renderDue = false // 2026-11-06: a wake-up's redraw held back while you type (busy()); made once you stop
4414
+ let freshAt = Date.now() // when the view's data was last known current (a refetch that answered)
4415
+ const WAKES = new Set(['visible', 'focus', 'online', 'pageshow-bfcache', 'resume', 'reconnect', 'resync', 'relay-switch'])
4416
+ async function refreshView(reason) {
4417
+ const editing = state.route && ['settings', 'setup'].includes(state.route.name)
4418
+ const id = liveOnScreen()
4419
+ const before = viewSignature(state.sessions, id ? liveUi.sig[id] : '')
4420
+ const wake = WAKES.has(reason)
4421
+ if (reason === 'reconnect' && Date.now() - wakeAt < 2500) return // the stream a wake-up just opened: that wake-up is reading the view
4422
+ const jobs = [id ? refreshLive(reason, { force: wake }) : null, refreshHealth()]
4423
+ if (editing) { /* Settings reads its own sections */ } else if (busy()) renderDue = true
4424
+ else jobs.push(render())
4425
+ await Promise.all(jobs.map((p) => Promise.resolve(p).catch(() => null)))
4426
+ if (!wake) return
4427
+ freshAt = Date.now()
4428
+ const id2 = liveOnScreen()
4429
+ if (viewSignature(state.sessions, id2 ? liveUi.sig[id2] : '') !== before) caughtUpCue()
4430
+ }
4431
+ // The quiet "Updated" a wake-up's refetch shows when it brought something new: a word and a check
4432
+ // (never color alone), no spinner, gone in a moment; still under reduced motion (app.css).
4433
+ function caughtUpCue() {
4434
+ let el = document.getElementById('caughtUp')
4435
+ if (!el) {
4436
+ el = document.createElement('div')
4437
+ el.id = 'caughtUp'; el.className = 'caught-up'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite')
4438
+ el.innerHTML = icon('check') + '<span>Updated</span>'
4439
+ document.body.appendChild(el)
4440
+ }
4441
+ el.hidden = false; el.classList.remove('out'); void el.offsetWidth; el.classList.add('in')
4442
+ clearTimeout(caughtUpCue.t)
4443
+ caughtUpCue.t = setTimeout(() => { el.classList.remove('in'); el.classList.add('out'); caughtUpCue.t = setTimeout(() => { el.hidden = true }, 400) }, 1600)
4300
4444
  }
4301
4445
  let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
4302
4446
  function onWake(ev) {
4303
4447
  if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
4304
- const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4448
+ const why = ev.type === 'resume' ? 'resume' : wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4305
4449
  if (!why) return
4306
4450
  liveTick() // a stale timer turns into "Reconnecting…" before anything else
4307
- const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
4308
- const away = awayAt ? Date.now() - awayAt : 0
4451
+ const now = Date.now()
4452
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, now, HEARTBEAT_MS + 2000)
4453
+ const away = Math.max(awayAt ? now - awayAt : 0, ev.away || 0)
4309
4454
  awayAt = 0
4310
- // a click back into the window a moment later isn't a wake-up; coming back after a while, a restore or the network is
4311
- if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
4312
- if (!wakeGate.due()) return
4313
- if (why === 'pageshow-bfcache' || dead) connect()
4455
+ // a click back into the window a moment later isn't a wake-up; coming back after a while, a
4456
+ // restore, the network or a frozen page is (live-wake.js catchUp)
4457
+ const plan = catchUp({ why, away, dead, freshAt: Math.max(freshAt, streamDog.lastAt || 0), now })
4458
+ if (!plan.refetch) return
4459
+ // the timer's freeze and the page's own events arrive together: one catch-up
4460
+ if (now - wakeAt < 2500 && !dead) return
4461
+ if (!wakeGate.due() && !plan.reconnect) return
4462
+ wakeAt = now
4463
+ if (plan.reconnect && now - (connect.at || 0) > 2000) connect()
4314
4464
  refreshView(why)
4315
4465
  }
4466
+ let wakeAt = 0
4316
4467
  for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
4468
+ // 2026-11-06: the relay moved this board to another Mac: a new stream from it, and its data
4469
+ window.addEventListener('leftoff-relay', (e) => {
4470
+ if (!(e.detail && e.detail.switched)) return
4471
+ clearTimeout(onWake.sw); onWake.sw = setTimeout(() => { connect(); refreshView('relay-switch') }, 300)
4472
+ })
4317
4473
  // 0.9.15 (ui/away.js): when this board was last on screen here
4318
4474
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
4319
4475
  window.addEventListener('pagehide', () => presence.here())
@@ -4919,6 +5075,7 @@ document.addEventListener('click', async (ev) => {
4919
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() }
4920
5076
  if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4921
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
4922
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 }
4923
5080
  if (a === 'dec-show') {
4924
5081
  const el = document.getElementById('dec-' + btn.dataset.id)
@@ -5179,9 +5336,6 @@ async function viewTimeline() {
5179
5336
  $app.innerHTML = html
5180
5337
  }
5181
5338
 
5182
- // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
5183
- // links there and Start here gets that session's Go / Resume.
5184
- const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
5185
5339
  function briefAction(id, sessions, viewer) {
5186
5340
  const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
5187
5341
  const s = sessions && sessions.get(id)
@@ -5193,75 +5347,10 @@ function briefAction(id, sessions, viewer) {
5193
5347
  return b || open
5194
5348
  }
5195
5349
 
5350
+ // 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
5196
5351
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
5197
- const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
5198
- // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
5199
- 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)
5200
- const out = []
5201
- let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
5202
- const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
5203
- const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
5204
- const href = (id) => '#/s/' + encodeURIComponent(id)
5205
- // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
5206
- // other "## " headings (Lists, Top of mind) are whole-folder sections.
5207
- // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
5208
- const openSec = (h) => {
5209
- if (inSec) out.push('</section>')
5210
- inSec = true
5211
- if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
5212
- else if (spaces.includes(h)) {
5213
- out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
5214
- '<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>')
5215
- } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
5216
- }
5217
- for (const raw of body.split('\n')) {
5218
- const mid = raw.match(MD_SID)
5219
- const id = mid ? mid[1] : null
5220
- const line = mid ? raw.slice(0, mid.index) : raw
5221
- // 0.9.8: Start here: "1. <step> — **Title** (repo)"
5222
- const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
5223
- if (st) {
5224
- openList('ol', 'start')
5225
- const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
5226
- 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>' +
5227
- (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
5228
- continue
5229
- }
5230
- if (/^- /.test(line)) {
5231
- openList('ul')
5232
- // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
5233
- const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
5234
- const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
5235
- // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
5236
- // session: the title links to it, the sentence under it, the tags quiet.
5237
- const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
5238
- // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
5239
- 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 }
5240
- 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 }
5241
- // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
5242
- const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
5243
- out.push('<li' + attrs + '>' + li); continue
5244
- }
5245
- if (/^\s{2,}\S/.test(line) && inList) {
5246
- // "Stopped at: … Next: …" is two lines, each with its label.
5247
- const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
5248
- 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>' : ''))
5249
- else out.push('<span class="sub">' + inline(t) + '</span>')
5250
- continue
5251
- }
5252
- closeList()
5253
- if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
5254
- if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
5255
- 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>') } }
5256
- // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
5257
- 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 }
5258
- else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
5259
- // 0.9.8: the Yesterday / Today opener, above the first section
5260
- else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
5261
- }
5262
- closeList()
5263
- if (inSec) out.push('</section>')
5264
- 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>' })
5265
5354
  }
5266
5355
 
5267
5356
  // "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
@@ -5370,6 +5459,7 @@ function repaintCalendar() {
5370
5459
  // repo chips, which only count the picked space.
5371
5460
  const digestUi = { standups: {}, date: null, open: null }
5372
5461
  async function viewDigest(date, machine) {
5462
+ digestUi.md = null
5373
5463
  const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
5374
5464
  const byDay = new Map()
5375
5465
  for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
@@ -5398,9 +5488,12 @@ async function viewDigest(date, machine) {
5398
5488
  if (pick) {
5399
5489
  try {
5400
5490
  // 0.9.8: the sessions too, for Start here's Go / Resume
5401
- 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
5402
5494
  if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
5403
5495
  const fm = d.meta || {}
5496
+ digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
5404
5497
  const madeAt = fm.generated_at
5405
5498
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
5406
5499
  const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
@@ -5408,12 +5501,13 @@ async function viewDigest(date, machine) {
5408
5501
  const others = (d.others || []).filter((x) => x.machine || x.shared)
5409
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>' : ''
5410
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>' : ''
5411
- 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 +
5412
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>'
5413
5506
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
5414
5507
  } else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
5415
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>'
5416
5509
  $app.innerHTML = html + '</div>'
5510
+ if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
5417
5511
  // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
5418
5512
  const art = $app.querySelector('article.md')
5419
5513
  const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
@@ -5439,7 +5533,7 @@ function paintDigestFilter() {
5439
5533
  const spaces = state.digestSpaces || []
5440
5534
  const sp = state.digestSpace
5441
5535
  const secs = [...document.querySelectorAll('.md section')]
5442
- 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)
5443
5537
  const tabsBox = document.getElementById('digestSpaces')
5444
5538
  if (tabsBox) {
5445
5539
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -6323,7 +6417,7 @@ async function viewSettings() {
6323
6417
  '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
6324
6418
  '<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
6325
6419
  '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
6326
- digest: digestSettings(c),
6420
+ digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
6327
6421
  spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
6328
6422
  sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
6329
6423
  agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
@@ -6370,6 +6464,7 @@ async function viewSettings() {
6370
6464
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
6371
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>'
6372
6466
  showSettingsSection(r)
6467
+ api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
6373
6468
  approvedUi.info = starts; approvedUi.err = ''
6374
6469
  fillApproved($app, starts) // approved folders: paths as text
6375
6470
  const ap = document.getElementById('f-start-approve')
@@ -8463,7 +8558,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
8463
8558
  tip.classList.add('on')
8464
8559
  }, delay)
8465
8560
  }
8466
- 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
8467
8562
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
8468
8563
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
8469
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() })
@@ -8605,14 +8700,14 @@ function keepPlace() {
8605
8700
  document.addEventListener('touchstart', (e) => {
8606
8701
  if (refreshing || e.touches.length !== 1) { pull = null; return }
8607
8702
  const t = e.touches[0]
8608
- pull = canStartPull({ coarse: coarse.matches, narrow: narrow.matches, scrollY: window.scrollY || document.scrollingElement.scrollTop, target: e.target, overlayOpen: overlayOpen() }) ? { x: t.clientX, y: t.clientY, step: null } : null
8703
+ pull = canStartPull({ coarse: coarse.matches, narrow: narrow.matches, scrollY: window.scrollY || document.scrollingElement.scrollTop, target: e.target, overlayOpen: overlayOpen() }) ? { x: t.clientX, y: t.clientY, step: null, head: onPullHead(e.target) } : null
8609
8704
  }, { passive: true })
8610
8705
  document.addEventListener('touchmove', (e) => {
8611
8706
  if (!pull || e.touches.length !== 1) return
8612
8707
  const t = e.touches[0]
8613
8708
  const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
8614
8709
  if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
8615
- if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
8710
+ if (!pull.head && (window.scrollY || 0) > 0) { pull = null; paint(null); return } // a pull from the bars at the top works scrolled anywhere
8616
8711
  if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
8617
8712
  pull.step = step
8618
8713
  paint(step)
@@ -9173,6 +9268,7 @@ document.addEventListener('click', async (ev) => {
9173
9268
  if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
9174
9269
  if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
9175
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
9176
9272
  if (action === 'wt-export-md') return wtExport('md')
9177
9273
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
9178
9274
  if (action === 'copy-fact') {
@@ -9186,8 +9282,9 @@ document.addEventListener('click', async (ev) => {
9186
9282
  if (action === 'copy-cmd') {
9187
9283
  const ok = await copy(btn.dataset.cmd)
9188
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)
9189
9286
  clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
9190
- 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)
9191
9288
  }
9192
9289
  return
9193
9290
  }
@@ -9807,6 +9904,7 @@ document.addEventListener('click', async (ev) => {
9807
9904
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
9808
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
9809
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)
9810
9908
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
9811
9909
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
9812
9910
  if (action === 'need-restore' || action === 'need-restore-all') {
@@ -10105,7 +10203,7 @@ async function todoDoneChange(cb) {
10105
10203
 
10106
10204
  // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
10107
10205
  // that owns it (through another Mac or the relay from a phone). Markdown is
10108
- // 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.
10109
10207
  const docUi = { r: null, name: '', printing: null }
10110
10208
  function docViewerEl() {
10111
10209
  let el = document.getElementById('docViewer')
@@ -10125,7 +10223,88 @@ function docViewerEl() {
10125
10223
  document.body.appendChild(el)
10126
10224
  return el
10127
10225
  }
10128
- 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
+ })
10129
10308
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
10130
10309
  async function openDoc(sessionId, { path, plan } = {}) {
10131
10310
  if (!sessionId || (!path && !plan)) return
@@ -10134,7 +10313,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
10134
10313
  const name = plan ? 'Plan' : String(path).split('/').pop()
10135
10314
  el.querySelector('.dv-title').textContent = name
10136
10315
  el.querySelector('.dv-sub').textContent = ''
10137
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10316
+ docBodyLoading(el)
10138
10317
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10139
10318
  docUi.r = null; docUi.name = name
10140
10319
  el.hidden = false
@@ -10146,6 +10325,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
10146
10325
  el.querySelector('.dv-title').textContent = r.name || name
10147
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(' · ')
10148
10327
  el.querySelector('.dv-body').innerHTML = docViewerBody(r)
10328
+ mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
10149
10329
  el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
10150
10330
  docUi.r = r; docUi.name = name
10151
10331
  const items = docMenuItems(r)
@@ -10170,7 +10350,7 @@ async function openToolOutput(sessionId, toolId, label) {
10170
10350
  const name = label || 'Tool output'
10171
10351
  el.querySelector('.dv-title').textContent = name
10172
10352
  el.querySelector('.dv-sub').textContent = ''
10173
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10353
+ docBodyLoading(el)
10174
10354
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10175
10355
  docUi.r = null; docUi.name = name
10176
10356
  el.hidden = false
@@ -10251,7 +10431,7 @@ async function loadRefFile() {
10251
10431
  el.querySelector('.dv-title').textContent = name
10252
10432
  el.querySelector('.dv-sub').textContent = ''
10253
10433
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
10254
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10434
+ docBodyLoading(el)
10255
10435
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10256
10436
  docUi.r = null; docUi.name = name; refUi.r = null
10257
10437
  const wasHidden = el.hidden
@@ -10267,7 +10447,9 @@ async function loadRefFile() {
10267
10447
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
10268
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(' · ')
10269
10449
  const body = el.querySelector('.dv-body')
10270
- 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 })
10271
10453
  el.querySelector('.dv-ptitle').textContent = r.name || name
10272
10454
  if (r.type === 'pdf' && r.base64) {
10273
10455
  const b64 = r.base64
@@ -10502,6 +10684,7 @@ let stream = null
10502
10684
  function connect() {
10503
10685
  if (stream) { try { stream.close() } catch {} }
10504
10686
  const es = stream = new EventSource('/v1/stream')
10687
+ connect.at = Date.now()
10505
10688
  const again = connect.opened // a reconnect: events may have been missed while it was down
10506
10689
  streamDog.opened()
10507
10690
  // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
@@ -10512,6 +10695,9 @@ function connect() {
10512
10695
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
10513
10696
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
10514
10697
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
10698
+ // 2026-11-06: the relay's poll lost events (its Mac restarted, another Mac took over, or more
10699
+ // happened than the daemon keeps): read the view again (ui/relay-client.js)
10700
+ es.addEventListener('resync', () => { if (stream === es) refreshView('resync') })
10515
10701
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
10516
10702
  // port; if the browser gave up on the stream, open a new one.
10517
10703
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }