@leftoff/cli 0.9.18-beta.8 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/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, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
7
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, 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
@@ -12,9 +14,9 @@ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
12
14
  import { changedKeys } from './settings-dirty.js'
13
15
  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
- import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
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…
@@ -43,27 +46,30 @@ import { mdInline, renderSection } from './changelog-md.js'
43
46
  import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
44
47
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
45
48
  import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
46
- import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
49
+ import { STATE_IC as PR_STATE_IC, cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick, WALK_IC as PR_WALK_IC, GH_MARK as PR_GH_IC, safeUrl as prSafeUrl } from './prs.js'
47
50
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
48
51
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
49
52
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
50
- import { sessionTitle, 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
53
+ import { cutTitle } from './session-title.js'
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
51
55
  import { statusView, elapsedText } from './live-status.js'
52
- 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
53
57
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
54
58
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
55
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'
56
- import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
60
+ import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
57
61
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
58
62
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
59
- 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 } 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
60
64
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
61
- 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
62
66
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
63
67
  import { lastReply, ghostShows } from './ghost.js'
68
+ import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
64
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
65
70
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
66
71
  import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
72
+ import { isHtmlDoc, remoteRefs, localRefs, previewDoc, previewBarHtml, PREVIEW_PAGE, VIEW_KEY as HP_VIEW_KEY } from './html-preview.js'
67
73
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
68
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
69
75
  import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
@@ -76,7 +82,10 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
76
82
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
77
83
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
78
84
  import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
79
- import { decidedListHtml, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
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)
88
+ import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
80
89
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
81
90
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
82
91
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -465,7 +474,7 @@ function copiedMark(btn) {
465
474
  btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
466
475
  }
467
476
  // 0.9.13: 'background' reads "2 tasks running", its tip naming them (ui/bgtasks.js)
468
- 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>'
469
478
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
470
479
  // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
471
480
  const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an asleep Mac's channel isn't listening
@@ -499,8 +508,11 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
499
508
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
500
509
  const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
501
510
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
502
- function cpExtras(cp, paths = null) {
503
- let h = ''
511
+ // decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
512
+ const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
513
+ const decidedSecHtml = (cp, log) => '<div class="cpx-sec cpx-decided" data-cp="' + esc(cp.id || '') + '"><div class="cpx-h">Decided</div>' + decidedListHtml(cp.decided, { esc, log, readOnly: true }) + '</div>'
514
+ function cpExtras(cp, paths = null, decided = null) {
515
+ let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
504
516
  for (const [k, title] of EXTRA_TITLES) {
505
517
  const v = cp[k]
506
518
  if (!v || (Array.isArray(v) && !v.length)) continue
@@ -519,11 +531,11 @@ function cpExtras(cp, paths = null) {
519
531
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
520
532
  // part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
521
533
  // paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
522
- function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
534
+ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
523
535
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
524
536
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
525
537
  const you = /^YOU:/i.test(cp.next || '')
526
- const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
538
+ const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
527
539
  const dd = (t, cls = '') => {
528
540
  if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
529
541
  const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
@@ -533,16 +545,20 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
533
545
  }
534
546
  // 0.9.15: what the session is for and how far along, above its latest step
535
547
  const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
536
- 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>'
537
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>' : '')
538
553
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
539
554
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
540
555
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
556
+ (rows ? '' : doneRow) +
541
557
  '<dt>Stopped</dt>' + dd(cp.stoppedAt) +
542
558
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
543
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>' : '') +
544
560
  '</dl>' +
545
- (more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths) + '</div></details>' : '')
561
+ (more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + esc(cpFoldSummary(cp, { decided: decidedHere(cp, decided) })) + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths, decided) + '</div></details>' : '')
546
562
  }
547
563
 
548
564
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -1131,8 +1147,27 @@ function card(s) {
1131
1147
  // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1132
1148
  const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1133
1149
  const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1134
- function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1135
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1150
+ // 2026-11-06 a PR range has no This session · Whole folder: it is the PR's own commits
1151
+ function wtBody(sid, extra = {}) { const sc = wtUi.scope.get(sid) || 'session'; return { scope: sc, ...(fileScope.get() === 'folder' && !isPrKey(sc) ? { files: 'folder' } : {}), ...extra } }
1152
+ function wtKey(sid) { const sc = wtUi.scope.get(sid) || 'session'; return sid + '|' + sc + (fileScope.get() === 'folder' && !isPrKey(sc) ? '|folder' : '') }
1153
+ // the ranges the daemon last listed for a session (any range's answer carries them all)
1154
+ function wtScopesOf(sid) { for (const [k, d] of wtUi.data) if (k.startsWith(sid + '|') && d && Array.isArray(d.scopes)) return d.scopes; return [] }
1155
+ function openWtPrMenu(btn, sid) {
1156
+ const cur = wtUi.scope.get(sid)
1157
+ const prs = wtScopesOf(sid).filter((x) => isPrKey(x.key))
1158
+ lastMenuAnchor = btn
1159
+ openActionMenu(btn, prs.map((x) => ({ label: x.label + (x.title ? ' · ' + x.title : ''), sub: prScopeTip({ repo: x.repo, state: x.state }), icon: PR_STATE_IC[x.state] || icon('git-pull-request'), cls: x.key === cur ? 'on' : '', on: x.key === cur, onLabel: 'shown now',
1160
+ attrs: { 'data-action': 'wt-scope', 'data-id': sid, 'data-scope': x.key } })), { title: 'Walk through one PR' })
1161
+ }
1162
+ // 2026-11-06 a PR row's Open: where to (the session's walkthrough on this PR, or GitHub)
1163
+ function openPrOpenMenu(btn) {
1164
+ const d = btn.dataset, n = '#' + d.num
1165
+ lastMenuAnchor = btn
1166
+ openActionMenu(btn, [
1167
+ { label: 'Walk me through it', sub: 'In Leftoff', icon: PR_WALK_IC, attrs: { 'data-action': 'wt-open', 'data-id': d.id, 'data-scope': d.scope } },
1168
+ ...(d.url ? [{ label: 'Open on GitHub', sub: (d.repo ? d.repo + ' ' : '') + n, icon: PR_GH_IC, attrs: { 'data-action': 'pr-gh', 'data-url': d.url } }] : []),
1169
+ ], { title: 'Open ' + n })
1170
+ }
1136
1171
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1137
1172
  function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
1138
1173
  function wtRestore(sid) {
@@ -1142,6 +1177,8 @@ function wtRestore(sid) {
1142
1177
  }
1143
1178
  async function wtOpen(sid, scope = null) {
1144
1179
  const s = wtSession(sid)
1180
+ // 2026-11-06 from the PRs page, a session this page hasn't loaded: its page opens on the walkthrough (wtRestore)
1181
+ if (!s && scope && sid) { try { sessionStorage.setItem('lo-wt', JSON.stringify({ sid, scope, view: 'story' })) } catch {} wtUi.restored = false; if (wtUi.sid === sid) wtUi.sid = null; location.hash = fullHref(sid); return }
1145
1182
  if (!s || s.agent) return
1146
1183
  wtUi.sid = sid
1147
1184
  if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
@@ -1232,7 +1269,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
1232
1269
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1233
1270
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1234
1271
  async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1235
- const k = wtKey(sid)
1272
+ let k = wtKey(sid)
1236
1273
  const had = wtUi.data.get(k)
1237
1274
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1238
1275
  if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
@@ -1241,6 +1278,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
1241
1278
  try {
1242
1279
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1243
1280
  if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1281
+ // 2026-11-06 the old 'pr' (remembered from before): the daemon answers for that PR's own range
1282
+ if (r && wtUi.scope.get(sid) === 'pr' && isPrKey(r.scope) && r.scope !== 'pr') { wtUi.scope.set(sid, r.scope); wtRemember(); wtUi.loading.delete(k); k = wtKey(sid); wtUi.loading.add(k) }
1244
1283
  wtUi.data.set(k, r)
1245
1284
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1246
1285
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1319,11 +1358,30 @@ function wtRestoreView(node) {
1319
1358
  wtStuck(node.querySelector('.wt-scroll'))
1320
1359
  }
1321
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
+ }
1322
1381
  function wtMarks(s) {
1323
1382
  const node = wtUi.node; if (!node || !s) return
1324
1383
  const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
1325
- const rev = reviewedSet(s, data.fingerprint)
1326
- 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)
1327
1385
  for (const a of node.querySelectorAll('article.wt-st')) {
1328
1386
  const on = rev.has(a.dataset.step)
1329
1387
  a.classList.toggle('done', on)
@@ -1333,6 +1391,8 @@ function wtMarks(s) {
1333
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
1334
1392
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1335
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)
1336
1396
  }
1337
1397
  async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1338
1398
  const s = wtSession(sid)
@@ -1358,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1358
1418
  function wtRevNext() {
1359
1419
  const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
1360
1420
  const slides = [...deck.children], cur = wtDeckCur()
1361
- 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)
1362
1425
  if (!st.show) return void wtSlideGo(cur + 1)
1363
- const step = slides[cur].dataset.step, sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1364
- 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)
1365
- 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 })
1366
1428
  wtSlideGo(st.to)
1367
1429
  }
1368
1430
  function wtGo(sid, step, { group = null, file = null } = {}) {
@@ -1413,8 +1475,7 @@ function wtSlideGo(i) {
1413
1475
  if (nav) {
1414
1476
  nav.querySelector('.wt-dprev').disabled = i === 0
1415
1477
  nav.querySelector('.wt-dnext').disabled = i === n - 1
1416
- const rv = nav.querySelector('#wtDrev'), rs = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), i)
1417
- 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))
1418
1479
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1419
1480
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1420
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') })
@@ -1477,11 +1538,26 @@ function wtExport(kind) {
1477
1538
  if (!s || !data || data.status !== 'ok') return
1478
1539
  const title = s.title || 'Session'
1479
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))
1480
- 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
+ }
1481
1553
  const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
1482
- 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()
1483
1555
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1556
+ toast('Saved ' + name + ' · one file, opens offline', 3000)
1557
+ return true
1484
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>'
1485
1561
  // ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
1486
1562
  // Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
1487
1563
  // number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
@@ -1615,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
1615
1691
  // Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
1616
1692
  // (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
1617
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
+ }
1618
1705
  function wtSpotGo(dir = 1) {
1619
1706
  const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1620
1707
  if (!data || data.status !== 'ok' || !wtUi.node) return
1621
1708
  const spots = wtSpots(data)
1622
1709
  if (!spots.length) return
1623
1710
  const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1624
- const i = nextSpotIndex(spots.length, cur, dir)
1711
+ const { rank, at } = wtSpotAt(data)
1712
+ const i = spotFrom(spots, rank, at, cur, dir)
1625
1713
  wtUi.spot.set(data.fingerprint, i)
1626
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) {
1627
1723
  const st = (data.steps || []).find((x) => x.id === sp.step)
1628
1724
  const sn = st && st.snippets[sp.snip]
1629
1725
  const rows = sn ? rowsOf(sn.patch) : []
1630
- const ri = rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1631
- for (const x of wtUi.node.querySelectorAll('.wt-spot-on')) x.classList.remove('wt-spot-on')
1632
- for (const x of wtUi.node.querySelectorAll('.wt-spot-tag')) x.remove()
1633
- const row = wtReveal('s:' + sp.step + ':' + sp.snip, ri)
1634
- // in the split drawing a removed line is the left half, an added one the right
1635
- const gd = row && row.closest('.wt-gd')
1636
- const cands = gd ? [...gd.querySelectorAll('[data-ri="' + ri + '"]')] : []
1637
- const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0] || null
1638
- if (at) {
1639
- at.classList.add('wt-spot-on')
1640
- const tag = document.createElement('span')
1641
- tag.className = 'wt-spot-tag'
1642
- tag.setAttribute('role', 'note')
1643
- tag.innerHTML = icon('crosshair') + '<span></span>'
1644
- tag.lastChild.textContent = sp.why || 'Worth a look'
1645
- at.appendChild(tag)
1646
- }
1647
- const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1648
- 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
+ })
1649
1759
  }
1650
1760
  // Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
1651
1761
  // only on a screen 1000px or wider, else unified (the toggle is hidden there)
@@ -1653,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
1653
1763
  function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
1654
1764
  function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
1655
1765
  if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
1656
- 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 }
1657
1767
  // the file list folds to a rail ([ or its button) and its edge drags; both kept per device
1658
1768
  function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
1659
1769
  function wtListUp() {
@@ -1820,6 +1930,9 @@ function repoRow(shown, f, tail = '') {
1820
1930
  return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
1821
1931
  }
1822
1932
 
1933
+ // 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
1934
+ // (the counts are per log; a chip's log is its sessions' decisionRepo)
1935
+ const repoReviewHtml = (c) => { const rv = projectsUi.repoReviews || {}; if (!Object.keys(rv).length) return ''; const s = (state.sessions || []).find((x) => x.decisionRepo && rv[x.decisionRepo] && repoKeyOf(x) === c.key); return s ? reviewCountHtml(rv[s.decisionRepo], { esc, cls: 'pchip-rc' }) : '' }
1823
1936
  // The repo chip row, shared by the board and the timeline: All, the top 8,
1824
1937
  // and More ▾ with a filter box. chips: repoChips() output.
1825
1938
  function chipBar(chips, selected, allCount, emptyNote, tail = '') {
@@ -1830,7 +1943,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1830
1943
  // with nothing in this view: a filter is never active out of sight.
1831
1944
  chips = orderSelectedFirst(chips, selected)
1832
1945
  const { shown: row, more } = splitChips(chips, selected, 8)
1833
- const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
1946
+ const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + repoReviewHtml(c) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
1834
1947
  const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
1835
1948
  let menu = ''
1836
1949
  if (more.length) {
@@ -1844,13 +1957,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1844
1957
  // ------------------------------------------------------------ projects (0.9.0)
1845
1958
  // A project is a named group of repos (projects.json in the synced folder).
1846
1959
  // Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
1847
- const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
1960
+ const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
1848
1961
  async function loadProjects({ fresh = false } = {}) {
1849
1962
  if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
1850
- try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
1963
+ try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.repoReviews = d.repoReviews || {}; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
1851
1964
  return projectsUi
1852
1965
  }
1853
- function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
1966
+ function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; if (d.repoReviews) projectsUi.repoReviews = d.repoReviews; projectsUi.at = Date.now() } }
1854
1967
  // The small chip next to the repo: tap = show only this project.
1855
1968
  function projChip(s) {
1856
1969
  const p = s && s.repoProject
@@ -2590,9 +2703,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
2590
2703
  const SESSION_CACHE_MAX = 8
2591
2704
  function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
2592
2705
  const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
2706
+ // 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
2707
+ // (the longest whole-word cut that fits, with …), never mid-word by the
2708
+ // ellipsis; the whole name is in its tip.
2709
+ function fitTitleWords(el, text) {
2710
+ el.textContent = text
2711
+ if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
2712
+ if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
2713
+ let lo = 4, hi = text.length, best = ''
2714
+ while (lo <= hi) {
2715
+ const mid = (lo + hi) >> 1
2716
+ el.textContent = cutTitle(text, mid)
2717
+ if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
2718
+ }
2719
+ el.textContent = best || cutTitle(text, lo)
2720
+ }
2721
+ addEventListener('resize', () => { const t = document.querySelector('#live.full .lf-title'); const full = t && t.getAttribute('data-tip'); if (full) fitTitleWords(t, full) }, { passive: true }) // a turned phone re-fits
2722
+ // 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
2723
+ const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
2724
+ function whereLineHtml(s) {
2725
+ const w = sessionWhere(s)
2726
+ return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
2727
+ }
2593
2728
  function sessionShellHtml(s) {
2594
2729
  const head = s
2595
- ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2730
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2596
2731
  : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
2597
2732
  return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
2598
2733
  '<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
@@ -2898,8 +3033,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2898
3033
  // title is being made, its spinner sits in the cluster.
2899
3034
  const fullTitle = sessionTitle(s)
2900
3035
  const title = canRename
2901
- ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2902
- : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
3036
+ ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
3037
+ : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
2903
3038
  const menu = fsMenuItems(s, mine)
2904
3039
  const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2905
3040
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
@@ -2945,9 +3080,9 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2945
3080
  side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
2946
3081
  side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
2947
3082
  decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
2948
- if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) noteSessionDecisions(s)
2949
- if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) side += foldHtml({ key: 'cp-decided', title: 'Decided', count: cps[0].decided.length, open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(cps[0].decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc }) // 2026-11-03 decision memory; 1k Accept / Reject / Edit right here
2950
- if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
3083
+ if (s.decisionRepo) noteSessionDecisions(s)
3084
+ side += '<div id="fsDecided">' + panelDecidedFold(s) + '</div>' // 2026-11-05 every decision the session made (its repo's log, else all its checkpoints'); 1k Accept / Reject / Edit right here
3085
+ if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths, { log: sessionLogBy(s) }) + '</div>' }, { esc })
2951
3086
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2952
3087
  if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
2953
3088
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
@@ -3376,10 +3511,64 @@ document.addEventListener('scroll', (e) => {
3376
3511
  const el = e.target
3377
3512
  if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3378
3513
  const side = el.closest('.fs-side')
3514
+ // 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
3515
+ // content under your finger by the head's change in height, which flipped it back (the sheet jumped)
3516
+ if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
3379
3517
  fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3380
3518
  if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3381
3519
  }, true)
3382
3520
 
3521
+ // 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
3522
+ // raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
3523
+ // it. Anything else is the content's own native scroll, untouched.
3524
+ const sheetMq = window.matchMedia('(max-width: 600px)')
3525
+ {
3526
+ let d = null
3527
+ const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
3528
+ document.addEventListener('touchstart', (e) => {
3529
+ d = null
3530
+ if (!sheetMq.matches || e.touches.length !== 1) return
3531
+ const side = e.target.closest && e.target.closest('#fsSide')
3532
+ if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
3533
+ const fromPeek = !!e.target.closest('.fs-peek')
3534
+ const inner = side.querySelector('.fs-side-in')
3535
+ if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
3536
+ // a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
3537
+ for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
3538
+ const p = e.touches[0]
3539
+ d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
3540
+ }, { passive: true })
3541
+ document.addEventListener('touchmove', (e) => {
3542
+ if (!d || e.touches.length !== 1) return
3543
+ const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
3544
+ if (!d.mode) {
3545
+ d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
3546
+ if (!d.mode) return
3547
+ if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
3548
+ d.travel = travel(d.side)
3549
+ d.side.classList.add('dragging')
3550
+ }
3551
+ if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
3552
+ const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
3553
+ d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
3554
+ }, { passive: false })
3555
+ const end = (e, cancel) => {
3556
+ const g = d
3557
+ d = null
3558
+ if (!g || g.mode !== 'drag') return
3559
+ g.side.classList.remove('dragging')
3560
+ g.side.style.transform = ''
3561
+ const p = e.changedTouches && e.changedTouches[0]
3562
+ if (cancel || !p) return
3563
+ if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
3564
+ toggleFsSheet()
3565
+ sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
3566
+ }
3567
+ }
3568
+ document.addEventListener('touchend', (e) => end(e, false), { passive: true })
3569
+ document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
3570
+ }
3571
+ const sheetUi = { ignoreClick: 0 }
3383
3572
  function toggleFsSheet() {
3384
3573
  const side = document.getElementById('fsSide')
3385
3574
  if (!side) return
@@ -3434,10 +3623,33 @@ function setFullClass(on) {
3434
3623
  // and the composer stays just above the keyboard. When the keyboard closes the
3435
3624
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3436
3625
  // its pan behind after a send, which put full screen ~300px down the screen).
3437
- const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
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 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3628
+ function fitSheetAside() {
3629
+ const html = document.documentElement, a = document.activeElement
3630
+ const typing = isTypingTarget(a)
3631
+ const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
3632
+ const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
3633
+ html.classList.toggle('lf-typing', aside)
3634
+ }
3635
+ // 2026-11-07: the thread at its end stays at its end as full screen changes height (the keyboard, the
3636
+ // sheet stepping aside): Chromium's scroll anchoring put a log that had grown back at its old scrollTop
3637
+ // as it shrank again, the scroll that made read as "you scrolled up" and the newest messages went
3638
+ // under the reply box. Measured before the change, put right after it.
3639
+ function logAtEnd() {
3640
+ const ol = document.querySelector('.live-box.full .live-log')
3641
+ return ol && ol._stick !== false && ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24 ? ol : null
3642
+ }
3643
+ function keepLogEnd(ol) { if (ol && ol.isConnected && ol.scrollHeight - ol.clientHeight - ol.scrollTop > 1) ol.scrollTop = ol.scrollHeight }
3438
3644
  function fitFullViewport() {
3645
+ const end = document.documentElement.classList.contains('live-full') ? logAtEnd() : null
3646
+ fitFullViewportNow()
3647
+ keepLogEnd(end)
3648
+ }
3649
+ function fitFullViewportNow() {
3439
3650
  const vv = window.visualViewport
3440
3651
  const html = document.documentElement, st = html.style
3652
+ fitSheetAside()
3441
3653
  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 }
3442
3654
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3443
3655
  const strip = document.getElementById('tabStrip')
@@ -3451,29 +3663,76 @@ function fitFullViewport() {
3451
3663
  html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3452
3664
  // the keyboard's gone: the page under full screen back where it was before the text box took focus
3453
3665
  if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
3666
+ if (typing) keepTypingInView()
3667
+ }
3668
+ // 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
3669
+ // bring it back): while you type, check where the composer really is against what you can see, from
3670
+ // this moment's numbers only, and if it isn't in view move full screen by the difference.
3671
+ // (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
3672
+ function keepTypingInView() {
3673
+ const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
3674
+ if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3675
+ const box = el.closest('.live-box.full')
3676
+ if (!box) return
3677
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3678
+ if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3679
+ }
3680
+ // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
3681
+ // page under it) fits full screen then and looks again a few beats after: a swap's last change can
3682
+ // come with no report of its own, and a passing bogus one is put right by the next look.
3683
+ function onViewportChange(ev) {
3684
+ fitFullViewport()
3685
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) { settleWhileTyping(1); followFrames() }
3686
+ }
3687
+ // 2026-11-06: and every frame for FOLLOW_FRAMES_MS after the box takes focus or iOS reports a change,
3688
+ // so the keyboard's animation (and iOS's numbers settling a frame or two late) is followed as it goes
3689
+ function followFrames() {
3690
+ if (typeof requestAnimationFrame !== 'function') return
3691
+ lfKb.until = performance.now() + FOLLOW_FRAMES_MS
3692
+ if (lfKb.raf) return
3693
+ const frame = () => {
3694
+ lfKb.raf = 0
3695
+ if (!isTypingTarget(document.activeElement) || !document.documentElement.classList.contains('live-full')) return
3696
+ fitFullViewport()
3697
+ if (performance.now() < lfKb.until) lfKb.raf = requestAnimationFrame(frame)
3698
+ }
3699
+ lfKb.raf = requestAnimationFrame(frame)
3454
3700
  }
3455
- if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
3456
- for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, fitFullViewport)
3701
+ function settleWhileTyping(from = 0) {
3702
+ clearTimeout(lfKb.settle)
3703
+ if (from >= SETTLE_WHILE_TYPING_MS.length) return
3704
+ let i = from
3705
+ const step = () => {
3706
+ if (!isTypingTarget(document.activeElement)) return
3707
+ fitFullViewport()
3708
+ if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
3709
+ }
3710
+ lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
3711
+ }
3712
+ if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
3713
+ window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
3714
+ for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
3457
3715
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
3458
3716
  document.addEventListener('focusin', (e) => {
3459
3717
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3460
- clearTimeout(lfKb.settle)
3461
3718
  if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3719
+ clearTimeout(lfKb.back); lfKb.blurAt = 0
3720
+ { const end = logAtEnd(); fitSheetAside(); keepLogEnd(end) } // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
3462
3721
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3463
3722
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3464
- let i = 0
3465
- const step = () => {
3466
- if (!isTypingTarget(document.activeElement)) return
3467
- fitFullViewport()
3468
- if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
3469
- }
3470
- lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
3723
+ settleWhileTyping(0)
3724
+ followFrames()
3471
3725
  })
3472
3726
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3473
3727
  // goes down, whatever iOS reports or doesn't on the way.
3474
3728
  document.addEventListener('focusout', (e) => {
3475
3729
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3476
3730
  clearTimeout(lfKb.settle)
3731
+ if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
3732
+ lfKb.blurAt = Date.now()
3733
+ clearTimeout(lfKb.back)
3734
+ lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
3735
+ }
3477
3736
  let i = 0
3478
3737
  const step = () => {
3479
3738
  fitFullViewport()
@@ -3490,7 +3749,9 @@ function fullAfterRender(sid) {
3490
3749
  if (box) {
3491
3750
  const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
3492
3751
  const t = box.querySelector('.lf-title')
3493
- if (t) t.textContent = sessionTitle(s) // the session's name, as text
3752
+ if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
3753
+ const w = box.querySelector('.lf-where')
3754
+ if (w) { w.textContent = sessionWhere(s); if (w.textContent) w.setAttribute('data-tip', wherePathOf(s) || w.textContent); else w.removeAttribute('data-tip') } // 2026-11-05: folder@branch, muted, its own line
3494
3755
  paintLfTabs()
3495
3756
  const ol = box.querySelector('.live-log')
3496
3757
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
@@ -3589,7 +3850,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
3589
3850
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
3590
3851
  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>' +
3591
3852
  '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
3592
- liveJumpHtml() + '</div>' +
3853
+ liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
3593
3854
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
3594
3855
  (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
3595
3856
  (strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
@@ -3954,6 +4215,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
3954
4215
  function liveAfterRender(sid) {
3955
4216
  for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
3956
4217
  bangAfterRender()
4218
+ slashAfterRender()
3957
4219
  const r = liveKeeper.after($app)
3958
4220
  hydrateAttThumbs(); barWidths($app)
3959
4221
  fullAfterRender(sid)
@@ -4095,15 +4357,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
4095
4357
  // Fetch the thread and diff it into the page, even while you type (busy()
4096
4358
  // holds full redraws back, never the thread). A turn that ended also redraws
4097
4359
  // the rest of the page (the pill stops pulsing) once you're not busy.
4098
- async function refreshLive(reason) {
4360
+ async function refreshLive(reason, { force = false } = {}) {
4099
4361
  const id = liveOnScreen()
4100
- if (!id || liveUi.polling) return false
4362
+ // 2026-11-06: a fetch that was in flight when the phone froze the page may never answer; coming
4363
+ // back (force) doesn't wait behind it, and its late answer is dropped (the newer one wins)
4364
+ if (!id || (liveUi.polling && !(force && Date.now() - (liveUi.triedAt || 0) > 1500))) return false
4365
+ const gen = liveUi.gen = (liveUi.gen || 0) + 1
4101
4366
  liveUi.polling = true; liveUi.triedAt = Date.now()
4102
4367
  let v
4103
- try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
4368
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { if (gen === liveUi.gen) liveUi.polling = false }
4369
+ if (gen !== liveUi.gen) return false
4104
4370
  if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
4105
4371
  const s = freshSession(state.sessions.find((x) => x.id === id))
4106
- const sig = liveSignature(v.messages)
4372
+ const sig = liveSignature(v.messages) + '#' + subSig(v.subagents) // 2026-11-06: a subagent's step changes the thread's line
4107
4373
  const before = liveUi.sig[id]
4108
4374
  liveUi.sig[id] = sig
4109
4375
  if (s && before !== sig) {
@@ -4113,6 +4379,8 @@ async function refreshLive(reason) {
4113
4379
  const tmp = document.createElement('div')
4114
4380
  tmp.innerHTML = liveBoxHtml(s, v)
4115
4381
  const fresh = tmp.querySelector('.live-thread')
4382
+ const slot = box.querySelector('.lv-subs-slot'), freshSlot = tmp.querySelector('.lv-subs-slot') // 2026-11-06: subagents working, outside the kept thread
4383
+ if (slot && freshSlot) slot.innerHTML = freshSlot.innerHTML
4116
4384
  if (fresh) {
4117
4385
  const next = cur.nextSibling
4118
4386
  liveKeeper.before(box)
@@ -4132,8 +4400,13 @@ async function refreshLive(reason) {
4132
4400
  // The safety net, and the stream's watchdog, on one slow timer.
4133
4401
  const streamDog = createWatchdog()
4134
4402
  const wakeGate = createWake()
4403
+ let tickAt = Date.now() // 2026-11-06: the timer's own clock, to notice a frozen page (live-wake.js resumedAfter)
4135
4404
  setInterval(() => {
4405
+ const was = tickAt; tickAt = Date.now()
4136
4406
  if (document.visibilityState === 'hidden') return
4407
+ const frozen = resumedAfter(was, tickAt)
4408
+ if (frozen) onWake({ type: 'resume', away: frozen })
4409
+ if (renderDue && !busy()) { renderDue = false; render() } // a redraw a wake-up couldn't make while you typed
4137
4410
  const id = liveOnScreen()
4138
4411
  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')
4139
4412
  if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
@@ -4141,27 +4414,66 @@ setInterval(() => {
4141
4414
  // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
4142
4415
  // bfcache restore, the network returns): fetch what's on screen at once, and
4143
4416
  // open a new stream if the old one missed its pings.
4144
- function refreshView(reason) {
4145
- if (liveOnScreen()) refreshLive(reason)
4146
- if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
4147
- refreshHealth()
4417
+ let renderDue = false // 2026-11-06: a wake-up's redraw held back while you type (busy()); made once you stop
4418
+ let freshAt = Date.now() // when the view's data was last known current (a refetch that answered)
4419
+ const WAKES = new Set(['visible', 'focus', 'online', 'pageshow-bfcache', 'resume', 'reconnect', 'resync', 'relay-switch'])
4420
+ async function refreshView(reason) {
4421
+ const editing = state.route && ['settings', 'setup'].includes(state.route.name)
4422
+ const id = liveOnScreen()
4423
+ const before = viewSignature(state.sessions, id ? liveUi.sig[id] : '')
4424
+ const wake = WAKES.has(reason)
4425
+ if (reason === 'reconnect' && Date.now() - wakeAt < 2500) return // the stream a wake-up just opened: that wake-up is reading the view
4426
+ const jobs = [id ? refreshLive(reason, { force: wake }) : null, refreshHealth()]
4427
+ if (editing) { /* Settings reads its own sections */ } else if (busy()) renderDue = true
4428
+ else jobs.push(render())
4429
+ await Promise.all(jobs.map((p) => Promise.resolve(p).catch(() => null)))
4430
+ if (!wake) return
4431
+ freshAt = Date.now()
4432
+ const id2 = liveOnScreen()
4433
+ if (viewSignature(state.sessions, id2 ? liveUi.sig[id2] : '') !== before) caughtUpCue()
4434
+ }
4435
+ // The quiet "Updated" a wake-up's refetch shows when it brought something new: a word and a check
4436
+ // (never color alone), no spinner, gone in a moment; still under reduced motion (app.css).
4437
+ function caughtUpCue() {
4438
+ let el = document.getElementById('caughtUp')
4439
+ if (!el) {
4440
+ el = document.createElement('div')
4441
+ el.id = 'caughtUp'; el.className = 'caught-up'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite')
4442
+ el.innerHTML = icon('check') + '<span>Updated</span>'
4443
+ document.body.appendChild(el)
4444
+ }
4445
+ el.hidden = false; el.classList.remove('out'); void el.offsetWidth; el.classList.add('in')
4446
+ clearTimeout(caughtUpCue.t)
4447
+ caughtUpCue.t = setTimeout(() => { el.classList.remove('in'); el.classList.add('out'); caughtUpCue.t = setTimeout(() => { el.hidden = true }, 400) }, 1600)
4148
4448
  }
4149
4449
  let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
4150
4450
  function onWake(ev) {
4151
4451
  if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
4152
- const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4452
+ const why = ev.type === 'resume' ? 'resume' : wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4153
4453
  if (!why) return
4154
4454
  liveTick() // a stale timer turns into "Reconnecting…" before anything else
4155
- const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
4156
- const away = awayAt ? Date.now() - awayAt : 0
4455
+ const now = Date.now()
4456
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, now, HEARTBEAT_MS + 2000)
4457
+ const away = Math.max(awayAt ? now - awayAt : 0, ev.away || 0)
4157
4458
  awayAt = 0
4158
- // 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
4159
- if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
4160
- if (!wakeGate.due()) return
4161
- if (why === 'pageshow-bfcache' || dead) connect()
4459
+ // a click back into the window a moment later isn't a wake-up; coming back after a while, a
4460
+ // restore, the network or a frozen page is (live-wake.js catchUp)
4461
+ const plan = catchUp({ why, away, dead, freshAt: Math.max(freshAt, streamDog.lastAt || 0), now })
4462
+ if (!plan.refetch) return
4463
+ // the timer's freeze and the page's own events arrive together: one catch-up
4464
+ if (now - wakeAt < 2500 && !dead) return
4465
+ if (!wakeGate.due() && !plan.reconnect) return
4466
+ wakeAt = now
4467
+ if (plan.reconnect && now - (connect.at || 0) > 2000) connect()
4162
4468
  refreshView(why)
4163
4469
  }
4470
+ let wakeAt = 0
4164
4471
  for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
4472
+ // 2026-11-06: the relay moved this board to another Mac: a new stream from it, and its data
4473
+ window.addEventListener('leftoff-relay', (e) => {
4474
+ if (!(e.detail && e.detail.switched)) return
4475
+ clearTimeout(onWake.sw); onWake.sw = setTimeout(() => { connect(); refreshView('relay-switch') }, 300)
4476
+ })
4165
4477
  // 0.9.15 (ui/away.js): when this board was last on screen here
4166
4478
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
4167
4479
  window.addEventListener('pagehide', () => presence.here())
@@ -4377,6 +4689,91 @@ function bangAfterRender() {
4377
4689
  if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
4378
4690
  }
4379
4691
  }
4692
+ // ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
4693
+ // The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
4694
+ const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
4695
+ function slashFor(ta) {
4696
+ const box = ta && ta.closest('.live-box .composer')
4697
+ if (!box || ta.disabled) return null
4698
+ if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
4699
+ return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
4700
+ }
4701
+ function paintSlash(ta, { keep = false } = {}) {
4702
+ const box = ta && ta.closest('.composer')
4703
+ if (!box) return null
4704
+ const menu = slashFor(ta)
4705
+ let el = box.querySelector('.slash-menu')
4706
+ if (!menu) {
4707
+ if (el) el.remove()
4708
+ box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
4709
+ slashUi.active = -1
4710
+ return null
4711
+ }
4712
+ if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
4713
+ const id = 'slashMenu-' + box.dataset.sid
4714
+ const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
4715
+ if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
4716
+ box.classList.add('has-slash')
4717
+ ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
4718
+ if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
4719
+ const on = box.querySelector('.slash-opt[aria-selected="true"]')
4720
+ if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
4721
+ return menu
4722
+ }
4723
+ function pickSlash(ta, value) {
4724
+ ta.value = value
4725
+ try { ta.setSelectionRange(value.length, value.length) } catch {}
4726
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
4727
+ ta.focus({ preventScroll: true })
4728
+ }
4729
+ document.addEventListener('input', (ev) => {
4730
+ const ta = ev.target
4731
+ if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
4732
+ if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
4733
+ paintSlash(ta)
4734
+ })
4735
+ // before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
4736
+ window.addEventListener('keydown', (ev) => {
4737
+ const ta = ev.target
4738
+ if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
4739
+ const box = ta.closest('.live-box .composer')
4740
+ if (!box || !box.querySelector('.slash-menu')) return
4741
+ const k = slashKey(ev)
4742
+ if (!k) return
4743
+ const menu = slashFor(ta)
4744
+ if (!menu) return
4745
+ ev.preventDefault(); ev.stopPropagation()
4746
+ if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
4747
+ if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
4748
+ const it = menu.items[slashUi.active]
4749
+ if (it && !it.off) pickSlash(ta, it.value)
4750
+ }, true)
4751
+ // a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
4752
+ // for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
4753
+ function pickSlashOpt(o) {
4754
+ if (!o || o.getAttribute('aria-disabled') === 'true') return false
4755
+ const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
4756
+ if (ta) pickSlash(ta, o.dataset.value)
4757
+ return !!ta
4758
+ }
4759
+ let slashTap = null
4760
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
4761
+ document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
4762
+ document.addEventListener('touchstart', (ev) => {
4763
+ const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
4764
+ slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
4765
+ }, { passive: true })
4766
+ document.addEventListener('touchend', (ev) => {
4767
+ const t = slashTap
4768
+ slashTap = null
4769
+ const p = ev.changedTouches && ev.changedTouches[0]
4770
+ if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
4771
+ if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
4772
+ }, { passive: false })
4773
+ function slashAfterRender() {
4774
+ const ta = document.activeElement
4775
+ if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
4776
+ }
4380
4777
  async function liveSendClick(btn) {
4381
4778
  const box = btn.closest('.live-box')
4382
4779
  const ta = box && box.querySelector('.live-text')
@@ -4503,10 +4900,11 @@ async function aroundWire(id) {
4503
4900
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4504
4901
  }
4505
4902
  }
4506
- function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4903
+ // decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
4904
+ function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
4507
4905
  return list.map((cp) =>
4508
4906
  '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4509
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4907
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
4510
4908
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4511
4909
  filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4512
4910
  (wtSid && cp === newestCp ? '<button type="button" class="linkish wt-since" data-action="wt-open" data-id="' + esc(wtSid) + '" data-scope="checkpoint">' + icon('route') + 'Walk through since here</button>' : '') + // 0.9.17
@@ -4517,21 +4915,26 @@ function cpHeadLine(cp) {
4517
4915
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4518
4916
  }
4519
4917
  // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4520
- const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
4918
+ const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
4521
4919
  const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
4522
- // The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
4920
+ // 2026-11-05 the session's rows in its repo's log (null until loaded, or with no repo), and the same keyed by text
4921
+ const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
4922
+ const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
4923
+ // The Decided fold: every decision the session made, shown whenever there's at least one
4924
+ function panelDecidedFold(s) {
4925
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
4926
+ if (!list.length) return ''
4927
+ return foldHtml({ key: 'cp-decided', title: 'Decided', count: sessionDecisionsCount(list), open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc })
4928
+ }
4929
+ // The session panel reads its repo's log (once per repo per visit, quietly); the old rows stay on screen until the new ones land
4523
4930
  function noteSessionDecisions(s) {
4524
4931
  const repo = s && s.decisionRepo
4525
4932
  if (!repo || decisionsUi.loadedRepo[repo]) return
4526
4933
  decisionsUi.loadedRepo[repo] = true
4527
4934
  api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4528
- const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
4529
- const by = {}
4530
- for (const d of v.decisions || []) if (d.sessionId === s.id) { by[norm(d.text)] = d; if (d.original) by[norm(d.original.text)] = d }
4531
- decisionsUi.bySession[s.id] = by
4532
- const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4533
- const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4534
- if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
4935
+ decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
4936
+ const ps = panelSess()
4937
+ if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
4535
4938
  }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4536
4939
  }
4537
4940
  // ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
@@ -4610,11 +5013,26 @@ document.addEventListener('submit', async (ev) => {
4610
5013
  } catch (e) { toast(e.message || String(e)) }
4611
5014
  }
4612
5015
  })
4613
- // 1k: the panel's Decided list, drawn again in place (editing one, or after the log loaded)
5016
+ // 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
5017
+ // count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
4614
5018
  function paintPanelDecided(s) {
4615
- const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4616
- const cp = s && (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4617
- if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
5019
+ const slot = document.getElementById('fsDecided')
5020
+ if (!slot || !s) return
5021
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
5022
+ const fold = slot.querySelector('[data-fold="cp-decided"]')
5023
+ if (!list.length) { if (fold) slot.innerHTML = ''; return }
5024
+ if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
5025
+ const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
5026
+ if (n && n.textContent !== cnt) n.textContent = cnt
5027
+ const box = fold.querySelector('.fs-fold-b')
5028
+ const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
5029
+ if (box && box.innerHTML !== html) box.innerHTML = html
5030
+ // the Timeline's older checkpoints read the same log
5031
+ const by = sessionLogBy(s)
5032
+ if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
5033
+ const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
5034
+ if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
5035
+ }
4618
5036
  }
4619
5037
  // 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
4620
5038
  function refreshPanelDecisions(s) {
@@ -4661,6 +5079,7 @@ document.addEventListener('click', async (ev) => {
4661
5079
  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() }
4662
5080
  if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4663
5081
  if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
5082
+ 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
4664
5083
  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 }
4665
5084
  if (a === 'dec-show') {
4666
5085
  const el = document.getElementById('dec-' + btn.dataset.id)
@@ -4737,11 +5156,15 @@ document.addEventListener('submit', (ev) => {
4737
5156
  decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4738
5157
  })
4739
5158
  // The project page's Decisions section
5159
+ // (2026-11-05) or a single repo's page: that repo's log (the page names its id)
5160
+ const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
4740
5161
  async function loadProjectDecisions(token, force = false) {
4741
5162
  if (!token) return
5163
+ const scopeArg = pageDecisionScope(token)
5164
+ if (!scopeArg) return
4742
5165
  try {
4743
- const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
4744
- decisionsUi.project = { token, v }
5166
+ const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
5167
+ decisionsUi.project = { token, v, scopeArg }
4745
5168
  paintProjectDecisions()
4746
5169
  } catch { decisionsUi.project = null }
4747
5170
  }
@@ -4749,9 +5172,10 @@ function paintProjectDecisions() {
4749
5172
  const box = document.getElementById('projDecisionsBox')
4750
5173
  const pd = decisionsUi.project
4751
5174
  if (!box || !pd || pd.token !== qaUi.token) return
4752
- box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
5175
+ const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
5176
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
4753
5177
  const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
4754
- if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref('project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)), cls: 'proj-review' })
5178
+ if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
4755
5179
  }
4756
5180
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4757
5181
  // your note and the files behind More (open or closed as you last left it on this device).
@@ -4916,9 +5340,6 @@ async function viewTimeline() {
4916
5340
  $app.innerHTML = html
4917
5341
  }
4918
5342
 
4919
- // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
4920
- // links there and Start here gets that session's Go / Resume.
4921
- const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
4922
5343
  function briefAction(id, sessions, viewer) {
4923
5344
  const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
4924
5345
  const s = sessions && sessions.get(id)
@@ -4930,75 +5351,10 @@ function briefAction(id, sessions, viewer) {
4930
5351
  return b || open
4931
5352
  }
4932
5353
 
5354
+ // 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
4933
5355
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4934
- const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4935
- // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
4936
- 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)
4937
- const out = []
4938
- let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4939
- const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
4940
- const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
4941
- const href = (id) => '#/s/' + encodeURIComponent(id)
4942
- // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
4943
- // other "## " headings (Lists, Top of mind) are whole-folder sections.
4944
- // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
4945
- const openSec = (h) => {
4946
- if (inSec) out.push('</section>')
4947
- inSec = true
4948
- if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
4949
- else if (spaces.includes(h)) {
4950
- out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
4951
- '<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>')
4952
- } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
4953
- }
4954
- for (const raw of body.split('\n')) {
4955
- const mid = raw.match(MD_SID)
4956
- const id = mid ? mid[1] : null
4957
- const line = mid ? raw.slice(0, mid.index) : raw
4958
- // 0.9.8: Start here: "1. <step> — **Title** (repo)"
4959
- const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
4960
- if (st) {
4961
- openList('ol', 'start')
4962
- const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
4963
- 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>' +
4964
- (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
4965
- continue
4966
- }
4967
- if (/^- /.test(line)) {
4968
- openList('ul')
4969
- // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
4970
- const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
4971
- const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
4972
- // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
4973
- // session: the title links to it, the sentence under it, the tags quiet.
4974
- const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
4975
- // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
4976
- 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 }
4977
- 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 }
4978
- // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
4979
- const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
4980
- out.push('<li' + attrs + '>' + li); continue
4981
- }
4982
- if (/^\s{2,}\S/.test(line) && inList) {
4983
- // "Stopped at: … Next: …" is two lines, each with its label.
4984
- const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
4985
- 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>' : ''))
4986
- else out.push('<span class="sub">' + inline(t) + '</span>')
4987
- continue
4988
- }
4989
- closeList()
4990
- if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
4991
- if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
4992
- 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>') } }
4993
- // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
4994
- 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 }
4995
- else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
4996
- // 0.9.8: the Yesterday / Today opener, above the first section
4997
- else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
4998
- }
4999
- closeList()
5000
- if (inSec) out.push('</section>')
5001
- return out.join('\n')
5356
+ return digestBodyHtml(md, { spaces, esc, action: (id) => briefAction(id, sessions, viewer),
5357
+ 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>' })
5002
5358
  }
5003
5359
 
5004
5360
  // "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
@@ -5107,6 +5463,7 @@ function repaintCalendar() {
5107
5463
  // repo chips, which only count the picked space.
5108
5464
  const digestUi = { standups: {}, date: null, open: null }
5109
5465
  async function viewDigest(date, machine) {
5466
+ digestUi.md = null
5110
5467
  const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
5111
5468
  const byDay = new Map()
5112
5469
  for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
@@ -5135,9 +5492,12 @@ async function viewDigest(date, machine) {
5135
5492
  if (pick) {
5136
5493
  try {
5137
5494
  // 0.9.8: the sessions too, for Start here's Go / Resume
5138
- 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)])
5495
+ const ver = ((state.route && state.route.params) || new URLSearchParams()).get('v') // 2026-11-06: an earlier version (ui/digest-versions.js)
5496
+ 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)])
5497
+ digestUi.shown = d
5139
5498
  if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
5140
5499
  const fm = d.meta || {}
5500
+ digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
5141
5501
  const madeAt = fm.generated_at
5142
5502
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
5143
5503
  const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
@@ -5145,12 +5505,13 @@ async function viewDigest(date, machine) {
5145
5505
  const others = (d.others || []).filter((x) => x.machine || x.shared)
5146
5506
  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>' : ''
5147
5507
  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>' : ''
5148
- html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
5508
+ 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 +
5149
5509
  '<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>'
5150
5510
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
5151
5511
  } else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
5152
5512
  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>'
5153
5513
  $app.innerHTML = html + '</div>'
5514
+ if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
5154
5515
  // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
5155
5516
  const art = $app.querySelector('article.md')
5156
5517
  const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
@@ -5176,7 +5537,7 @@ function paintDigestFilter() {
5176
5537
  const spaces = state.digestSpaces || []
5177
5538
  const sp = state.digestSpace
5178
5539
  const secs = [...document.querySelectorAll('.md section')]
5179
- for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
5540
+ 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)
5180
5541
  const tabsBox = document.getElementById('digestSpaces')
5181
5542
  if (tabsBox) {
5182
5543
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -6060,7 +6421,7 @@ async function viewSettings() {
6060
6421
  '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
6061
6422
  '<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
6062
6423
  '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
6063
- digest: digestSettings(c),
6424
+ digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
6064
6425
  spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
6065
6426
  sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
6066
6427
  agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
@@ -6107,6 +6468,7 @@ async function viewSettings() {
6107
6468
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
6108
6469
  '<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>'
6109
6470
  showSettingsSection(r)
6471
+ api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
6110
6472
  approvedUi.info = starts; approvedUi.err = ''
6111
6473
  fillApproved($app, starts) // approved folders: paths as text
6112
6474
  const ap = document.getElementById('f-start-approve')
@@ -6993,7 +7355,7 @@ async function viewProject(token) {
6993
7355
  const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
6994
7356
  qaUi.page = page
6995
7357
  paintProject()
6996
- if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
7358
+ if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
6997
7359
  if (fresh) window.scrollTo(0, 0)
6998
7360
  const r = state.route || {}
6999
7361
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -7013,7 +7375,7 @@ function paintProject() {
7013
7375
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
7014
7376
  $app.innerHTML = out.html
7015
7377
  fillAsk($app, out.keys)
7016
- if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
7378
+ if (p.scope && (p.scope.kind === 'project' || p.decisionRepo)) { // 2026-11-03 decision memory: the project's log and where it's kept; 2026-11-05 a repo's log
7017
7379
  const at = document.getElementById('projAsks')
7018
7380
  if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
7019
7381
  paintProjectDecisions()
@@ -8200,7 +8562,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
8200
8562
  tip.classList.add('on')
8201
8563
  }, delay)
8202
8564
  }
8203
- 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
8565
+ 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
8204
8566
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
8205
8567
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
8206
8568
  document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
@@ -8342,14 +8704,14 @@ function keepPlace() {
8342
8704
  document.addEventListener('touchstart', (e) => {
8343
8705
  if (refreshing || e.touches.length !== 1) { pull = null; return }
8344
8706
  const t = e.touches[0]
8345
- 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
8707
+ 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
8346
8708
  }, { passive: true })
8347
8709
  document.addEventListener('touchmove', (e) => {
8348
8710
  if (!pull || e.touches.length !== 1) return
8349
8711
  const t = e.touches[0]
8350
8712
  const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
8351
8713
  if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
8352
- if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
8714
+ if (!pull.head && (window.scrollY || 0) > 0) { pull = null; paint(null); return } // a pull from the bars at the top works scrolled anywhere
8353
8715
  if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
8354
8716
  pull.step = step
8355
8717
  paint(step)
@@ -8849,14 +9211,17 @@ document.addEventListener('click', async (ev) => {
8849
9211
  // 0.9.16 the session panel (ui/fs-side.js)
8850
9212
  if (action === 'fs-side') return toggleFsSide()
8851
9213
  if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
8852
- if (action === 'fs-sheet') return toggleFsSheet()
9214
+ if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
8853
9215
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
8854
9216
  if (action === 'tab-pin') return togglePin(id)
8855
9217
  // 0.9.17 Walk me through it
8856
- if (action === 'wt-open') return wtOpen(id, btn.dataset.scope || null)
9218
+ if (action === 'pr-open') return openPrOpenMenu(btn)
9219
+ if (action === 'pr-gh') { closeActionMenu(); const u = prSafeUrl(btn.dataset.url); if (u) window.open(u, '_blank', 'noopener,noreferrer'); return }
9220
+ if (action === 'wt-open') { if (btn.closest('.lo-actmenu')) closeActionMenu(); return wtOpen(id, btn.dataset.scope || null) }
8857
9221
  if (action === 'wt-close') return wtClose()
8858
9222
  if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
8859
- if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
9223
+ if (action === 'wt-prs') return openWtPrMenu(btn, btn.dataset.id || wtUi.sid)
9224
+ if (action === 'wt-scope') { if (btn.disabled) return; if (btn.closest('.lo-actmenu')) closeActionMenu(); wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
8860
9225
  if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8861
9226
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8862
9227
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
@@ -8907,6 +9272,7 @@ document.addEventListener('click', async (ev) => {
8907
9272
  if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
8908
9273
  if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
8909
9274
  if (action === 'wt-export-html') return wtExport('html')
9275
+ if (action === 'digest-save-html') { if (digestUi.md) saveHtmlFile(digestExportHtml(digestUi.md, { date: digestUi.mdDate }), exportFileName('digest', digestUi.mdDate || '')); return } // 2026-11-06
8910
9276
  if (action === 'wt-export-md') return wtExport('md')
8911
9277
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
8912
9278
  if (action === 'copy-fact') {
@@ -8920,8 +9286,9 @@ document.addEventListener('click', async (ev) => {
8920
9286
  if (action === 'copy-cmd') {
8921
9287
  const ok = await copy(btn.dataset.cmd)
8922
9288
  if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
9289
+ 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)
8923
9290
  clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
8924
- btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
9291
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', btn._label); btn.dataset.tip = 'Copy' }, 1500)
8925
9292
  }
8926
9293
  return
8927
9294
  }
@@ -9541,6 +9908,7 @@ document.addEventListener('click', async (ev) => {
9541
9908
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
9542
9909
  if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
9543
9910
  if (action === 'doc-close') return closeDoc()
9911
+ if (action === 'hp-mode' || action === 'hp-show' || action === 'hp-allow' || action === 'hp-block' || action === 'hp-reload' || action === 'hp-show-local') return hpAction(action, btn)
9544
9912
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
9545
9913
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
9546
9914
  if (action === 'need-restore' || action === 'need-restore-all') {
@@ -9839,7 +10207,7 @@ async function todoDoneChange(cb) {
9839
10207
 
9840
10208
  // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
9841
10209
  // that owns it (through another Mac or the relay from a phone). Markdown is
9842
- // rendered from escaped text; HTML files show as source; anything else downloads.
10210
+ // rendered from escaped text; HTML files open on Preview (sandboxed) or Code; anything else downloads.
9843
10211
  const docUi = { r: null, name: '', printing: null }
9844
10212
  function docViewerEl() {
9845
10213
  let el = document.getElementById('docViewer')
@@ -9859,7 +10227,89 @@ function docViewerEl() {
9859
10227
  document.body.appendChild(el)
9860
10228
  return el
9861
10229
  }
9862
- 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') }
10230
+ 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') }
10231
+ function docBodyLoading(el) { const b = el.querySelector('.dv-body'); b.classList.remove('dv-hpv'); hpUi.r = null; b.innerHTML = '<div class="empty">Opening…</div>' }
10232
+ // 2026-11-06 An HTML file: Preview (ui/html-preview.js, a sandboxed frame; remote
10233
+ // resources blocked until Allow, for this view only) or Code, remembered per device.
10234
+ const hpUi = { r: null, code: '', mode: 'preview', remote: false, open: false, refs: [], locals: [], openLocal: false }
10235
+ function hpViewPref() { try { return localStorage.getItem(HP_VIEW_KEY) === 'code' ? 'code' : 'preview' } catch { return 'preview' } }
10236
+ function mountHtmlPreview(body, r, code, { line = null } = {}) {
10237
+ if (!isHtmlDoc(r)) return false
10238
+ Object.assign(hpUi, { r, code, mode: line ? 'code' : hpViewPref(), remote: false, open: false, refs: remoteRefs(r.text), locals: localRefs(r.text), openLocal: false })
10239
+ body.innerHTML = '<div class="hp-bar"></div><div class="hp-main"></div>'
10240
+ paintHpBar(body); paintHpMain(body)
10241
+ return true
10242
+ }
10243
+ function paintHpBar(body) { const bar = body.querySelector('.hp-bar'); if (bar) bar.innerHTML = previewBarHtml({ ...hpUi, icon }) }
10244
+ function paintHpMain(body) {
10245
+ const main = body.querySelector('.hp-main')
10246
+ if (!main || !hpUi.r) return
10247
+ const pv = hpUi.mode === 'preview'
10248
+ body.classList.toggle('dv-hpv', pv)
10249
+ if (!pv) { main.innerHTML = hpUi.code; return }
10250
+ main.textContent = ''
10251
+ const f = document.createElement('iframe')
10252
+ f.className = 'hp-frame'
10253
+ f.setAttribute('sandbox', 'allow-scripts')
10254
+ f.setAttribute('referrerpolicy', 'no-referrer')
10255
+ f.setAttribute('aria-label', 'Preview of ' + (hpUi.r.name || 'the page'))
10256
+ f._hpDoc = previewDoc(hpUi.r.text, { remote: hpUi.remote })
10257
+ f._hpPing = 0
10258
+ // once the page is in, every load is checked: only the page we wrote answers the ping
10259
+ // (ui/html-preview.js GUARD), so silence means it went somewhere else
10260
+ f.addEventListener('load', () => {
10261
+ if (f._hpDoc != null || !f.isConnected || !f.contentWindow) return
10262
+ const n = ++f._hpPing
10263
+ f.contentWindow.postMessage({ t: 'lo-hp-ping', n }, '*')
10264
+ clearTimeout(f._hpWait)
10265
+ f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1000)
10266
+ })
10267
+ f.src = PREVIEW_PAGE + (hpUi.remote ? '?remote=1' : '')
10268
+ main.appendChild(f)
10269
+ }
10270
+ // The page sent itself somewhere (CSP can't stop that): the frame goes, and the bar says so.
10271
+ function hpStopped(f) {
10272
+ if (!f || !f.isConnected) return
10273
+ clearTimeout(f._hpWait)
10274
+ const main = f.closest('.hp-main')
10275
+ f.remove()
10276
+ 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>'
10277
+ }
10278
+ function hpAction(action, btn) {
10279
+ const body = document.querySelector('#docViewer .dv-body')
10280
+ if (!body || !hpUi.r) return
10281
+ if (action === 'hp-mode') {
10282
+ const m = btn.dataset.mode === 'code' ? 'code' : 'preview'
10283
+ if (m === hpUi.mode) return
10284
+ hpUi.mode = m
10285
+ try { localStorage.setItem(HP_VIEW_KEY, m) } catch {}
10286
+ paintHpBar(body); paintHpMain(body); body.scrollTop = 0
10287
+ } else if (action === 'hp-show') { hpUi.open = !hpUi.open; paintHpBar(body) }
10288
+ else if (action === 'hp-show-local') { hpUi.openLocal = !hpUi.openLocal; paintHpBar(body) }
10289
+ else if (action === 'hp-reload') paintHpMain(body)
10290
+ else if (action === 'hp-allow' || action === 'hp-block') { hpUi.remote = action === 'hp-allow'; paintHpBar(body); paintHpMain(body) }
10291
+ const again = body.querySelector('[data-action="' + action + '"]') || body.querySelector('[data-action="' + (action === 'hp-allow' ? 'hp-block' : action === 'hp-block' ? 'hp-allow' : action) + '"]')
10292
+ if (again && action !== 'hp-mode') again.focus({ preventScroll: true })
10293
+ }
10294
+ // The sandbox asks for its page once (a frame that navigated away gets nothing), says where a
10295
+ // clicked link goes (it doesn't open), and passes Esc on, since keys in the frame stay there.
10296
+ window.addEventListener('message', (ev) => {
10297
+ const d = ev.data
10298
+ if (!d || typeof d !== 'object' || typeof d.t !== 'string' || !d.t.startsWith('lo-hp-')) return
10299
+ const f = [...document.querySelectorAll('#docViewer .hp-frame')].find((x) => x.contentWindow === ev.source)
10300
+ if (!f) return
10301
+ if (d.t === 'lo-hp-ready' && f._hpDoc != null) {
10302
+ const html = f._hpDoc; f._hpDoc = null
10303
+ ev.source.postMessage({ t: 'lo-hp-html', html }, '*')
10304
+ const n = ++f._hpPing // the write itself may not fire a load: check once now too
10305
+ ev.source.postMessage({ t: 'lo-hp-ping', n }, '*')
10306
+ f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1500)
10307
+ }
10308
+ else if (d.t === 'lo-hp-pong') { if (d.n === f._hpPing) { f._hpPong = d.n; clearTimeout(f._hpWait) } }
10309
+ else if (d.t === 'lo-hp-left') hpStopped(f)
10310
+ 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)
10311
+ else if (d.t === 'lo-hp-esc') closeDoc()
10312
+ })
9863
10313
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9864
10314
  async function openDoc(sessionId, { path, plan } = {}) {
9865
10315
  if (!sessionId || (!path && !plan)) return
@@ -9868,7 +10318,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
9868
10318
  const name = plan ? 'Plan' : String(path).split('/').pop()
9869
10319
  el.querySelector('.dv-title').textContent = name
9870
10320
  el.querySelector('.dv-sub').textContent = ''
9871
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10321
+ docBodyLoading(el)
9872
10322
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
9873
10323
  docUi.r = null; docUi.name = name
9874
10324
  el.hidden = false
@@ -9880,6 +10330,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
9880
10330
  el.querySelector('.dv-title').textContent = r.name || name
9881
10331
  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(' · ')
9882
10332
  el.querySelector('.dv-body').innerHTML = docViewerBody(r)
10333
+ mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
9883
10334
  el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
9884
10335
  docUi.r = r; docUi.name = name
9885
10336
  const items = docMenuItems(r)
@@ -9904,7 +10355,7 @@ async function openToolOutput(sessionId, toolId, label) {
9904
10355
  const name = label || 'Tool output'
9905
10356
  el.querySelector('.dv-title').textContent = name
9906
10357
  el.querySelector('.dv-sub').textContent = ''
9907
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10358
+ docBodyLoading(el)
9908
10359
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
9909
10360
  docUi.r = null; docUi.name = name
9910
10361
  el.hidden = false
@@ -9985,7 +10436,7 @@ async function loadRefFile() {
9985
10436
  el.querySelector('.dv-title').textContent = name
9986
10437
  el.querySelector('.dv-sub').textContent = ''
9987
10438
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
9988
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10439
+ docBodyLoading(el)
9989
10440
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
9990
10441
  docUi.r = null; docUi.name = name; refUi.r = null
9991
10442
  const wasHidden = el.hidden
@@ -10001,7 +10452,9 @@ async function loadRefFile() {
10001
10452
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
10002
10453
  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(' · ')
10003
10454
  const body = el.querySelector('.dv-body')
10004
- body.innerHTML = refBody(r, { line: refUi.line })
10455
+ const code = refBody(r, { line: refUi.line })
10456
+ body.innerHTML = code
10457
+ mountHtmlPreview(body, r, code, { line: refUi.line })
10005
10458
  el.querySelector('.dv-ptitle').textContent = r.name || name
10006
10459
  if (r.type === 'pdf' && r.base64) {
10007
10460
  const b64 = r.base64
@@ -10236,6 +10689,7 @@ let stream = null
10236
10689
  function connect() {
10237
10690
  if (stream) { try { stream.close() } catch {} }
10238
10691
  const es = stream = new EventSource('/v1/stream')
10692
+ connect.at = Date.now()
10239
10693
  const again = connect.opened // a reconnect: events may have been missed while it was down
10240
10694
  streamDog.opened()
10241
10695
  // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
@@ -10246,6 +10700,9 @@ function connect() {
10246
10700
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
10247
10701
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
10248
10702
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
10703
+ // 2026-11-06: the relay's poll lost events (its Mac restarted, another Mac took over, or more
10704
+ // happened than the daemon keeps): read the view again (ui/relay-client.js)
10705
+ es.addEventListener('resync', () => { if (stream === es) refreshView('resync') })
10249
10706
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
10250
10707
  // port; if the browser gave up on the stream, open a new one.
10251
10708
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }