@leftoff/cli 0.9.18-beta.9 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // Leftoff web UI: plain JS, no build step, no CDN. Talks to the daemon's
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
+ import { mcpSetupHtml, mcpStateHtml } from './mcp-setup.js'
5
+ import { paintDigestVersion } from './digest-versions.js'
4
6
  import { icon } from './icons.js'
5
- import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
7
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, 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')
@@ -3470,7 +3682,21 @@ function keepTypingInView() {
3470
3682
  // come with no report of its own, and a passing bogus one is put right by the next look.
3471
3683
  function onViewportChange(ev) {
3472
3684
  fitFullViewport()
3473
- if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
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)
3474
3700
  }
3475
3701
  function settleWhileTyping(from = 0) {
3476
3702
  clearTimeout(lfKb.settle)
@@ -3490,15 +3716,23 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
3490
3716
  document.addEventListener('focusin', (e) => {
3491
3717
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3492
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
3493
3721
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3494
3722
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3495
3723
  settleWhileTyping(0)
3724
+ followFrames()
3496
3725
  })
3497
3726
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3498
3727
  // goes down, whatever iOS reports or doesn't on the way.
3499
3728
  document.addEventListener('focusout', (e) => {
3500
3729
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3501
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
+ }
3502
3736
  let i = 0
3503
3737
  const step = () => {
3504
3738
  fitFullViewport()
@@ -3515,7 +3749,9 @@ function fullAfterRender(sid) {
3515
3749
  if (box) {
3516
3750
  const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
3517
3751
  const t = box.querySelector('.lf-title')
3518
- 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
3519
3755
  paintLfTabs()
3520
3756
  const ol = box.querySelector('.live-log')
3521
3757
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
@@ -3614,7 +3850,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
3614
3850
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
3615
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>' +
3616
3852
  '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
3617
- liveJumpHtml() + '</div>' +
3853
+ liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
3618
3854
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
3619
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
3620
3856
  (strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
@@ -3979,6 +4215,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
3979
4215
  function liveAfterRender(sid) {
3980
4216
  for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
3981
4217
  bangAfterRender()
4218
+ slashAfterRender()
3982
4219
  const r = liveKeeper.after($app)
3983
4220
  hydrateAttThumbs(); barWidths($app)
3984
4221
  fullAfterRender(sid)
@@ -4120,15 +4357,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
4120
4357
  // Fetch the thread and diff it into the page, even while you type (busy()
4121
4358
  // holds full redraws back, never the thread). A turn that ended also redraws
4122
4359
  // the rest of the page (the pill stops pulsing) once you're not busy.
4123
- async function refreshLive(reason) {
4360
+ async function refreshLive(reason, { force = false } = {}) {
4124
4361
  const id = liveOnScreen()
4125
- 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
4126
4366
  liveUi.polling = true; liveUi.triedAt = Date.now()
4127
4367
  let v
4128
- 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
4129
4370
  if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
4130
4371
  const s = freshSession(state.sessions.find((x) => x.id === id))
4131
- 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
4132
4373
  const before = liveUi.sig[id]
4133
4374
  liveUi.sig[id] = sig
4134
4375
  if (s && before !== sig) {
@@ -4138,6 +4379,8 @@ async function refreshLive(reason) {
4138
4379
  const tmp = document.createElement('div')
4139
4380
  tmp.innerHTML = liveBoxHtml(s, v)
4140
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
4141
4384
  if (fresh) {
4142
4385
  const next = cur.nextSibling
4143
4386
  liveKeeper.before(box)
@@ -4157,8 +4400,13 @@ async function refreshLive(reason) {
4157
4400
  // The safety net, and the stream's watchdog, on one slow timer.
4158
4401
  const streamDog = createWatchdog()
4159
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)
4160
4404
  setInterval(() => {
4405
+ const was = tickAt; tickAt = Date.now()
4161
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
4162
4410
  const id = liveOnScreen()
4163
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')
4164
4412
  if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
@@ -4166,27 +4414,66 @@ setInterval(() => {
4166
4414
  // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
4167
4415
  // bfcache restore, the network returns): fetch what's on screen at once, and
4168
4416
  // open a new stream if the old one missed its pings.
4169
- function refreshView(reason) {
4170
- if (liveOnScreen()) refreshLive(reason)
4171
- if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
4172
- 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)
4173
4448
  }
4174
4449
  let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
4175
4450
  function onWake(ev) {
4176
4451
  if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
4177
- 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 })
4178
4453
  if (!why) return
4179
4454
  liveTick() // a stale timer turns into "Reconnecting…" before anything else
4180
- const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
4181
- 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)
4182
4458
  awayAt = 0
4183
- // 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
4184
- if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
4185
- if (!wakeGate.due()) return
4186
- 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()
4187
4468
  refreshView(why)
4188
4469
  }
4470
+ let wakeAt = 0
4189
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
+ })
4190
4477
  // 0.9.15 (ui/away.js): when this board was last on screen here
4191
4478
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
4192
4479
  window.addEventListener('pagehide', () => presence.here())
@@ -4402,6 +4689,91 @@ function bangAfterRender() {
4402
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 } }
4403
4690
  }
4404
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
+ }
4405
4777
  async function liveSendClick(btn) {
4406
4778
  const box = btn.closest('.live-box')
4407
4779
  const ta = box && box.querySelector('.live-text')
@@ -4528,10 +4900,11 @@ async function aroundWire(id) {
4528
4900
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4529
4901
  }
4530
4902
  }
4531
- 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) {
4532
4905
  return list.map((cp) =>
4533
4906
  '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4534
- 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 }) +
4535
4908
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4536
4909
  filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4537
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
@@ -4542,21 +4915,26 @@ function cpHeadLine(cp) {
4542
4915
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4543
4916
  }
4544
4917
  // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4545
- 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 }
4546
4919
  const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
4547
- // 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
4548
4930
  function noteSessionDecisions(s) {
4549
4931
  const repo = s && s.decisionRepo
4550
4932
  if (!repo || decisionsUi.loadedRepo[repo]) return
4551
4933
  decisionsUi.loadedRepo[repo] = true
4552
4934
  api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4553
- const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
4554
- const by = {}
4555
- 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 }
4556
- decisionsUi.bySession[s.id] = by
4557
- const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4558
- const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4559
- 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)
4560
4938
  }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4561
4939
  }
4562
4940
  // ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
@@ -4635,11 +5013,26 @@ document.addEventListener('submit', async (ev) => {
4635
5013
  } catch (e) { toast(e.message || String(e)) }
4636
5014
  }
4637
5015
  })
4638
- // 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
4639
5018
  function paintPanelDecided(s) {
4640
- const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4641
- const cp = s && (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4642
- 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
+ }
4643
5036
  }
4644
5037
  // 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
4645
5038
  function refreshPanelDecisions(s) {
@@ -4686,6 +5079,7 @@ document.addEventListener('click', async (ev) => {
4686
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() }
4687
5080
  if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4688
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
4689
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 }
4690
5084
  if (a === 'dec-show') {
4691
5085
  const el = document.getElementById('dec-' + btn.dataset.id)
@@ -4762,11 +5156,15 @@ document.addEventListener('submit', (ev) => {
4762
5156
  decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4763
5157
  })
4764
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 }
4765
5161
  async function loadProjectDecisions(token, force = false) {
4766
5162
  if (!token) return
5163
+ const scopeArg = pageDecisionScope(token)
5164
+ if (!scopeArg) return
4767
5165
  try {
4768
- const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
4769
- decisionsUi.project = { token, v }
5166
+ const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
5167
+ decisionsUi.project = { token, v, scopeArg }
4770
5168
  paintProjectDecisions()
4771
5169
  } catch { decisionsUi.project = null }
4772
5170
  }
@@ -4774,9 +5172,10 @@ function paintProjectDecisions() {
4774
5172
  const box = document.getElementById('projDecisionsBox')
4775
5173
  const pd = decisionsUi.project
4776
5174
  if (!box || !pd || pd.token !== qaUi.token) return
4777
- 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 })
4778
5177
  const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
4779
- 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' })
4780
5179
  }
4781
5180
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4782
5181
  // your note and the files behind More (open or closed as you last left it on this device).
@@ -4941,9 +5340,6 @@ async function viewTimeline() {
4941
5340
  $app.innerHTML = html
4942
5341
  }
4943
5342
 
4944
- // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
4945
- // links there and Start here gets that session's Go / Resume.
4946
- const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
4947
5343
  function briefAction(id, sessions, viewer) {
4948
5344
  const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
4949
5345
  const s = sessions && sessions.get(id)
@@ -4955,75 +5351,10 @@ function briefAction(id, sessions, viewer) {
4955
5351
  return b || open
4956
5352
  }
4957
5353
 
5354
+ // 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
4958
5355
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4959
- const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4960
- // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
4961
- 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)
4962
- const out = []
4963
- let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4964
- const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
4965
- const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
4966
- const href = (id) => '#/s/' + encodeURIComponent(id)
4967
- // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
4968
- // other "## " headings (Lists, Top of mind) are whole-folder sections.
4969
- // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
4970
- const openSec = (h) => {
4971
- if (inSec) out.push('</section>')
4972
- inSec = true
4973
- if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
4974
- else if (spaces.includes(h)) {
4975
- out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
4976
- '<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>')
4977
- } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
4978
- }
4979
- for (const raw of body.split('\n')) {
4980
- const mid = raw.match(MD_SID)
4981
- const id = mid ? mid[1] : null
4982
- const line = mid ? raw.slice(0, mid.index) : raw
4983
- // 0.9.8: Start here: "1. <step> — **Title** (repo)"
4984
- const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
4985
- if (st) {
4986
- openList('ol', 'start')
4987
- const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
4988
- 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>' +
4989
- (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
4990
- continue
4991
- }
4992
- if (/^- /.test(line)) {
4993
- openList('ul')
4994
- // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
4995
- const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
4996
- const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
4997
- // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
4998
- // session: the title links to it, the sentence under it, the tags quiet.
4999
- const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
5000
- // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
5001
- 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 }
5002
- 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 }
5003
- // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
5004
- const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
5005
- out.push('<li' + attrs + '>' + li); continue
5006
- }
5007
- if (/^\s{2,}\S/.test(line) && inList) {
5008
- // "Stopped at: … Next: …" is two lines, each with its label.
5009
- const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
5010
- 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>' : ''))
5011
- else out.push('<span class="sub">' + inline(t) + '</span>')
5012
- continue
5013
- }
5014
- closeList()
5015
- if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
5016
- if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
5017
- 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>') } }
5018
- // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
5019
- 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 }
5020
- else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
5021
- // 0.9.8: the Yesterday / Today opener, above the first section
5022
- else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
5023
- }
5024
- closeList()
5025
- if (inSec) out.push('</section>')
5026
- 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>' })
5027
5358
  }
5028
5359
 
5029
5360
  // "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
@@ -5132,6 +5463,7 @@ function repaintCalendar() {
5132
5463
  // repo chips, which only count the picked space.
5133
5464
  const digestUi = { standups: {}, date: null, open: null }
5134
5465
  async function viewDigest(date, machine) {
5466
+ digestUi.md = null
5135
5467
  const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
5136
5468
  const byDay = new Map()
5137
5469
  for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
@@ -5160,9 +5492,12 @@ async function viewDigest(date, machine) {
5160
5492
  if (pick) {
5161
5493
  try {
5162
5494
  // 0.9.8: the sessions too, for Start here's Go / Resume
5163
- 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
5164
5498
  if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
5165
5499
  const fm = d.meta || {}
5500
+ digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
5166
5501
  const madeAt = fm.generated_at
5167
5502
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
5168
5503
  const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
@@ -5170,12 +5505,13 @@ async function viewDigest(date, machine) {
5170
5505
  const others = (d.others || []).filter((x) => x.machine || x.shared)
5171
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>' : ''
5172
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>' : ''
5173
- 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 +
5174
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>'
5175
5510
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
5176
5511
  } else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
5177
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>'
5178
5513
  $app.innerHTML = html + '</div>'
5514
+ if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
5179
5515
  // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
5180
5516
  const art = $app.querySelector('article.md')
5181
5517
  const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
@@ -5201,7 +5537,7 @@ function paintDigestFilter() {
5201
5537
  const spaces = state.digestSpaces || []
5202
5538
  const sp = state.digestSpace
5203
5539
  const secs = [...document.querySelectorAll('.md section')]
5204
- 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)
5205
5541
  const tabsBox = document.getElementById('digestSpaces')
5206
5542
  if (tabsBox) {
5207
5543
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -6085,7 +6421,7 @@ async function viewSettings() {
6085
6421
  '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
6086
6422
  '<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
6087
6423
  '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
6088
- digest: digestSettings(c),
6424
+ digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
6089
6425
  spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
6090
6426
  sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
6091
6427
  agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
@@ -6132,6 +6468,7 @@ async function viewSettings() {
6132
6468
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
6133
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>'
6134
6470
  showSettingsSection(r)
6471
+ api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
6135
6472
  approvedUi.info = starts; approvedUi.err = ''
6136
6473
  fillApproved($app, starts) // approved folders: paths as text
6137
6474
  const ap = document.getElementById('f-start-approve')
@@ -7018,7 +7355,7 @@ async function viewProject(token) {
7018
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
7019
7356
  qaUi.page = page
7020
7357
  paintProject()
7021
- 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
7022
7359
  if (fresh) window.scrollTo(0, 0)
7023
7360
  const r = state.route || {}
7024
7361
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -7038,7 +7375,7 @@ function paintProject() {
7038
7375
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
7039
7376
  $app.innerHTML = out.html
7040
7377
  fillAsk($app, out.keys)
7041
- 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
7042
7379
  const at = document.getElementById('projAsks')
7043
7380
  if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
7044
7381
  paintProjectDecisions()
@@ -8225,7 +8562,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
8225
8562
  tip.classList.add('on')
8226
8563
  }, delay)
8227
8564
  }
8228
- 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
8229
8566
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
8230
8567
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
8231
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() })
@@ -8367,14 +8704,14 @@ function keepPlace() {
8367
8704
  document.addEventListener('touchstart', (e) => {
8368
8705
  if (refreshing || e.touches.length !== 1) { pull = null; return }
8369
8706
  const t = e.touches[0]
8370
- 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
8371
8708
  }, { passive: true })
8372
8709
  document.addEventListener('touchmove', (e) => {
8373
8710
  if (!pull || e.touches.length !== 1) return
8374
8711
  const t = e.touches[0]
8375
8712
  const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
8376
8713
  if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
8377
- 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
8378
8715
  if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
8379
8716
  pull.step = step
8380
8717
  paint(step)
@@ -8874,14 +9211,17 @@ document.addEventListener('click', async (ev) => {
8874
9211
  // 0.9.16 the session panel (ui/fs-side.js)
8875
9212
  if (action === 'fs-side') return toggleFsSide()
8876
9213
  if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
8877
- if (action === 'fs-sheet') return toggleFsSheet()
9214
+ if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
8878
9215
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
8879
9216
  if (action === 'tab-pin') return togglePin(id)
8880
9217
  // 0.9.17 Walk me through it
8881
- 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) }
8882
9221
  if (action === 'wt-close') return wtClose()
8883
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 }
8884
- 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) }
8885
9225
  if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8886
9226
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8887
9227
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
@@ -8932,6 +9272,7 @@ document.addEventListener('click', async (ev) => {
8932
9272
  if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
8933
9273
  if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
8934
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
8935
9276
  if (action === 'wt-export-md') return wtExport('md')
8936
9277
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
8937
9278
  if (action === 'copy-fact') {
@@ -8945,8 +9286,9 @@ document.addEventListener('click', async (ev) => {
8945
9286
  if (action === 'copy-cmd') {
8946
9287
  const ok = await copy(btn.dataset.cmd)
8947
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)
8948
9290
  clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
8949
- 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)
8950
9292
  }
8951
9293
  return
8952
9294
  }
@@ -9566,6 +9908,7 @@ document.addEventListener('click', async (ev) => {
9566
9908
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
9567
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
9568
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)
9569
9912
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
9570
9913
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
9571
9914
  if (action === 'need-restore' || action === 'need-restore-all') {
@@ -9864,7 +10207,7 @@ async function todoDoneChange(cb) {
9864
10207
 
9865
10208
  // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
9866
10209
  // that owns it (through another Mac or the relay from a phone). Markdown is
9867
- // 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.
9868
10211
  const docUi = { r: null, name: '', printing: null }
9869
10212
  function docViewerEl() {
9870
10213
  let el = document.getElementById('docViewer')
@@ -9884,7 +10227,89 @@ function docViewerEl() {
9884
10227
  document.body.appendChild(el)
9885
10228
  return el
9886
10229
  }
9887
- 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
+ })
9888
10313
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9889
10314
  async function openDoc(sessionId, { path, plan } = {}) {
9890
10315
  if (!sessionId || (!path && !plan)) return
@@ -9893,7 +10318,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
9893
10318
  const name = plan ? 'Plan' : String(path).split('/').pop()
9894
10319
  el.querySelector('.dv-title').textContent = name
9895
10320
  el.querySelector('.dv-sub').textContent = ''
9896
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10321
+ docBodyLoading(el)
9897
10322
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
9898
10323
  docUi.r = null; docUi.name = name
9899
10324
  el.hidden = false
@@ -9905,6 +10330,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
9905
10330
  el.querySelector('.dv-title').textContent = r.name || name
9906
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(' · ')
9907
10332
  el.querySelector('.dv-body').innerHTML = docViewerBody(r)
10333
+ mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
9908
10334
  el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
9909
10335
  docUi.r = r; docUi.name = name
9910
10336
  const items = docMenuItems(r)
@@ -9929,7 +10355,7 @@ async function openToolOutput(sessionId, toolId, label) {
9929
10355
  const name = label || 'Tool output'
9930
10356
  el.querySelector('.dv-title').textContent = name
9931
10357
  el.querySelector('.dv-sub').textContent = ''
9932
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10358
+ docBodyLoading(el)
9933
10359
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
9934
10360
  docUi.r = null; docUi.name = name
9935
10361
  el.hidden = false
@@ -10010,7 +10436,7 @@ async function loadRefFile() {
10010
10436
  el.querySelector('.dv-title').textContent = name
10011
10437
  el.querySelector('.dv-sub').textContent = ''
10012
10438
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
10013
- el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
10439
+ docBodyLoading(el)
10014
10440
  const dl = el.querySelector('.dv-dl'); dl.hidden = true
10015
10441
  docUi.r = null; docUi.name = name; refUi.r = null
10016
10442
  const wasHidden = el.hidden
@@ -10026,7 +10452,9 @@ async function loadRefFile() {
10026
10452
  el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
10027
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(' · ')
10028
10454
  const body = el.querySelector('.dv-body')
10029
- 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 })
10030
10458
  el.querySelector('.dv-ptitle').textContent = r.name || name
10031
10459
  if (r.type === 'pdf' && r.base64) {
10032
10460
  const b64 = r.base64
@@ -10261,6 +10689,7 @@ let stream = null
10261
10689
  function connect() {
10262
10690
  if (stream) { try { stream.close() } catch {} }
10263
10691
  const es = stream = new EventSource('/v1/stream')
10692
+ connect.at = Date.now()
10264
10693
  const again = connect.opened // a reconnect: events may have been missed while it was down
10265
10694
  streamDog.opened()
10266
10695
  // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
@@ -10271,6 +10700,9 @@ function connect() {
10271
10700
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
10272
10701
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
10273
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') })
10274
10706
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
10275
10707
  // port; if the browser gave up on the stream, open a new one.
10276
10708
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }