@leftoff/cli 0.9.17-beta.8 → 0.9.17

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
@@ -52,7 +52,11 @@ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
52
52
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
53
53
  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'
54
54
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
55
- import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName } from './semdiff.js' // 0.9.17 Walk me through it
55
+ import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
+ import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
57
+ import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT } from './semdiff.js' // 0.9.17 Walk me through it
58
+ import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
59
+ import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
56
60
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
57
61
  import { lastReply, ghostShows } from './ghost.js'
58
62
  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
@@ -64,7 +68,7 @@ import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Cla
64
68
  import { codeCopyClick } from './code-copy.js' // 0.9.15: Copy on code blocks
65
69
  import { refBody, refErrorBody, refBarHtml, sizeLabel as refSize } from './file-view.js' // 0.9.15: open a file from a message
66
70
  import { refAgainst } from './file-links.js'
67
- import { hydrateDiffs, renderDiff } from './diff-view.js' // 0.9.8: a file's changes drawn like GitHub's
71
+ import { hydrateDiffs, renderDiff, renderSplit } from './diff-view.js' // 0.9.8: a file's changes drawn like GitHub's
68
72
  import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLogin, offerDismissed as loginOfferOff, dismissOffer as loginDismissOffer } from './login-banner.js' // 0.9.14 Claude login expiry
69
73
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
70
74
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
@@ -248,6 +252,7 @@ document.addEventListener('keydown', (e) => {
248
252
  if (!isNavKey(e, { typing, term, mac: navMac }) || !navRailQ.matches) return
249
253
  if (document.querySelector('.lo-menu, .cmdk:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden])')) return
250
254
  if ([...document.querySelectorAll('[role="dialog"][aria-modal="true"]')].some((d) => d.id !== 'navDrawer' && !d.closest('[hidden]') && d.getClientRects().length)) return
255
+ if (e.key === '[' && wtListUp()) return // 2026-10-30 in a walkthrough with a file list, [ folds that list (the walkthrough's own keys)
251
256
  e.preventDefault()
252
257
  toggleNav()
253
258
  }, true)
@@ -282,7 +287,7 @@ const $app = document.getElementById('app')
282
287
  const scrollKeep = createScrollKeep()
283
288
  scrollKeep.start()
284
289
  // 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
285
- const wtUi = { sid: null, scope: new Map(), view: 'story', filter: 'all', data: new Map(), loading: new Set(), error: new Map(), cur: new Map(), code: new Set(), groups: new Set(), pending: [], ask: '', node: null, sig: '', restored: false, fellBack: new Set() }
290
+ const wtUi = { sid: null, scope: new Map(), view: 'story', filter: 'all', data: new Map(), loading: new Set(), error: new Map(), cur: new Map(), code: new Set(), groups: new Set(), pending: [], ask: '', node: null, sig: '', restored: false, fellBack: new Set(), picks: [], picksFp: '', spot: new Map(), checking: new Set() } // 2026-10-30 picks: lines picked for Ask (ui/walk-lines.js); spot: fp -> where Next spot is
286
291
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
287
292
  if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
288
293
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -335,6 +340,8 @@ const ORDER = [
335
340
  ['stale', 'Stale'],
336
341
  ['ended', 'Ended today'],
337
342
  ]
343
+ const sideFolds = createFoldStore(typeof localStorage !== 'undefined' ? localStorage : null) // 2026-10-29 the session panel's folds (ui/side-fold.js)
344
+ let fsScrolled = null // the session panel scrolled past its head (its id): a redraw keeps the slim pinned head
338
345
  const LABEL = { needs_input: 'needs you', active: 'active', background: 'tasks running', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
339
346
 
340
347
  // ------------------------------------------------------------ helpers
@@ -468,14 +475,14 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
468
475
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
469
476
  const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
470
477
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
471
- function cpExtras(cp) {
478
+ function cpExtras(cp, paths = null) {
472
479
  let h = ''
473
480
  for (const [k, title] of EXTRA_TITLES) {
474
481
  const v = cp[k]
475
482
  if (!v || (Array.isArray(v) && !v.length)) continue
476
483
  h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
477
484
  if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
478
- else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
485
+ else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(paths ? relPath(f.path, paths) : f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
479
486
  else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
480
487
  h += '</div>'
481
488
  }
@@ -486,7 +493,9 @@ function cpExtras(cp) {
486
493
  // md (0.9.5): the session page's full view renders a markdown checkpoint (a
487
494
  // model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
488
495
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
489
- function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null } = {}) {
496
+ // part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
497
+ // paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
498
+ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
490
499
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
491
500
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
492
501
  const you = /^YOU:/i.test(cp.next || '')
@@ -500,6 +509,8 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
500
509
  }
501
510
  // 0.9.15: what the session is for and how far along, above its latest step
502
511
  const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
512
+ 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>'
513
+ 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>' : '')
503
514
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
504
515
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
505
516
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
@@ -507,7 +518,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
507
518
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
508
519
  (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>' : '') +
509
520
  '</dl>' +
510
- (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) + '</div></details>' : '')
521
+ (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>' : '')
511
522
  }
512
523
 
513
524
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -1112,8 +1123,26 @@ async function wtOpen(sid, scope = null) {
1112
1123
  if (!isFullLive(sid)) { location.hash = fullHref(sid); return }
1113
1124
  if (phoneNow() && fsSheetUi.open === sid) fsSheetUi.open = null
1114
1125
  repaintSession(sid)
1126
+ const had = wtUi.data.get(wtKey(sid))
1127
+ if (had && had.status === 'ok' && !wtUi.loading.has(wtKey(sid))) wtCheck(sid) // 2026-10-30 back to one it has: shown at once, checked quietly
1115
1128
  wtLoad(sid)
1116
1129
  }
1130
+ // 2026-10-30 (asked: "is it stored in a way that we can nav away and nav back to it?") coming back to a
1131
+ // walkthrough this page already has shows it at once (no model run, no spinner) and asks the daemon,
1132
+ // quietly, whether the change moved on since (peek: it never writes one); if it did, the stale bar shows
1133
+ async function wtCheck(sid) {
1134
+ const k = wtKey(sid)
1135
+ if (wtUi.checking.has(k)) return
1136
+ wtUi.checking.add(k)
1137
+ try {
1138
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1139
+ const had = wtUi.data.get(k)
1140
+ if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1141
+ wtUi.data.set(k, r)
1142
+ if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1143
+ }
1144
+ } catch {} finally { wtUi.checking.delete(k) }
1145
+ }
1117
1146
  function wtClose() {
1118
1147
  const sid = wtUi.sid
1119
1148
  document.documentElement.classList.remove('wt-open')
@@ -1139,30 +1168,47 @@ async function wtSizeFetch(sid) {
1139
1168
  try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
1140
1169
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1141
1170
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1142
- slot.innerHTML = sizeLineHtml(data, { esc, icon })
1171
+ slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
1143
1172
  const b = slot.closest('.fs-wt'); if (b) b.classList.toggle('none', !!(data && data.files === 0))
1144
1173
  }
1145
1174
  if (loPop) loPop.refresh('wt', sid)
1146
1175
  }
1176
+ // 2026-10-30 the sidebar's "3 of 7 reviewed": your marks on the walkthrough change-size names (pending ones too)
1177
+ function wtRevCount(s, size) {
1178
+ const fp = size && size.walk && size.walk.fp
1179
+ if (!s || !fp) return 0
1180
+ const rev = reviewedSet(s, fp)
1181
+ for (const x of wtUi.pending) if (x.fp === fp) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1182
+ return rev.size
1183
+ }
1184
+ function wtSizeRepaint(sid) {
1185
+ const data = (wtSize.get(sid) || {}).data
1186
+ for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
1187
+ }
1147
1188
  let loPop = null
1148
1189
  // 0.9.17: nothing new since the last checkpoint → the whole session, with a quiet note ("No changes" only when that's empty too)
1149
1190
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1150
- async function wtLoad(sid, { fresh = false } = {}) {
1191
+ // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
+ // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1193
+ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1151
1194
  const k = wtKey(sid)
1152
1195
  const had = wtUi.data.get(k)
1153
- if (!fresh && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1154
- if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh)) return wtFill(wtSession(sid))
1196
+ if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1197
+ if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
1155
1198
  wtUi.loading.add(k); wtUi.error.delete(k)
1156
1199
  wtFill(wtSession(sid))
1157
1200
  try {
1158
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1201
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1159
1202
  wtUi.data.set(k, r)
1160
1203
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
+ // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
1205
+ if (r && r.status === 'ok' && Array.isArray(r.reviewedSteps) && r.fingerprint) for (const x of [wtSession(sid), state.sessions.find((y) => y.id === sid)]) if (x) x.reviewed = { ...(x.reviewed || {}), [r.fingerprint]: r.reviewedSteps }
1161
1206
  if (r && r.status === 'no_range' && wtUi.scope.get(sid) === 'checkpoint') { wtUi.scope.set(sid, 'session'); wtUi.loading.delete(k); return wtLoad(sid) }
1162
1207
  if (r && r.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); wtUi.loading.delete(k); return wtLoad(sid) }
1163
1208
  } catch (e) { wtUi.error.set(k, (e && e.message) || 'Couldn’t read the change.') }
1164
1209
  wtUi.loading.delete(k)
1165
1210
  if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1211
+ const z = wtSize.get(sid); if (z && !z.pending) { z.at = 0; wtSizeFetch(sid) } // 2026-10-30 the sidebar's reviewed count follows the walkthrough just read
1166
1212
  }
1167
1213
  // The node for the page's #wtMount: built when what it shows changed, else moved back in.
1168
1214
  function wtFill(s) {
@@ -1173,8 +1219,8 @@ function wtFill(s) {
1173
1219
  const fp = data && data.fingerprint
1174
1220
  const rev = reviewedSet(s, fp)
1175
1221
  for (const x of wtUi.pending) if (x.fp === fp) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1176
- const ui = { scope: wtUi.scope.get(s.id) || 'session', view: wtUi.view, filter: wtUi.filter, open: wtUi.code, groups: wtUi.groups, cur: wtUi.cur.get(k) || null, loading: wtUi.loading.has(k), error: wtUi.error.get(k) || null, ask: wtUi.ask, fellBack: wtUi.fellBack.has(s.id), deck: wtDeckOn(), slide: wtSlideGet(fp, wtUi.filter) }
1177
- const sig = k + '|' + (data ? (data.fingerprint || data.status) + (data.cached ? 'c' : '') : '-') + '|' + JSON.stringify([ui.view, ui.filter, ui.loading, ui.error, ui.fellBack, ui.deck, [...wtUi.code], (data && data.scopes || []).map((x) => x.key + x.available).join()])
1222
+ const ui = { scope: wtUi.scope.get(s.id) || 'session', view: wtUi.view, filter: wtUi.filter, open: wtUi.code, groups: wtUi.groups, cur: wtUi.cur.get(k) || null, loading: wtUi.loading.has(k), error: wtUi.error.get(k) || null, ask: wtUi.ask, fellBack: wtUi.fellBack.has(s.id), deck: wtDeckOn(), slide: wtSlideGet(fp, wtUi.filter), sf: fp ? sfState(fp) : {}, split: wtSplitOn(), rail: wtRailOn(), spot: fp && wtUi.spot.has(fp) ? wtUi.spot.get(fp) : -1 }
1223
+ const sig = k + '|' + (data ? (data.fingerprint || data.status) + (data.cached ? 'c' : '') : '-') + '|' + JSON.stringify([ui.view, ui.filter, ui.loading, ui.error, ui.fellBack, ui.deck, [...wtUi.code], (data && data.scopes || []).map((x) => x.key + x.available).join(), ui.split, data && data.stale ? data.stale.fingerprint + ':' + data.stale.count : ''])
1178
1224
  let node = wtUi.node
1179
1225
  if (!node || wtUi.sig !== sig) {
1180
1226
  // (the old node may be out of the page already, reading 0: what was recorded as you scrolled wins)
@@ -1188,9 +1234,10 @@ function wtFill(s) {
1188
1234
  node.innerHTML = walkthroughHtml({ s, data, ui, reviewed: rev, esc, icon, when })
1189
1235
  wtUi.node = node; wtUi.sig = sig
1190
1236
  mount.replaceChildren(node)
1191
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1237
+ wtHydrate(node, data)
1192
1238
  wtStuck(document.querySelector('.wt-scroll')) // 0.9.18
1193
1239
  wtDeckPaint(node, { first: true }) // 0.9.18 the slideshow: its progress, its dots in view
1240
+ wtSideWidth(node) // 2026-10-30 the file list's width, as you left it
1194
1241
  const sc = node.querySelector('#wtScroll'); if (sc && keep) sc.scrollTop = keep
1195
1242
  const sd = node.querySelector('.wt-side'); if (sd && keepSide) sd.scrollTop = keepSide
1196
1243
  } else {
@@ -1203,6 +1250,7 @@ function wtFill(s) {
1203
1250
  }
1204
1251
  wtMarks(s)
1205
1252
  }
1253
+ wtPicksPaint() // 2026-10-30 picked lines, their chips, the floating Ask
1206
1254
  if (!data && !ui.loading && !ui.error) setTimeout(() => wtLoad(s.id), 0) // opened by a reload: read it now
1207
1255
  }
1208
1256
  // 0.9.18 where each scroll box in the walkthrough was (the slide or long page, the file list, the
@@ -1244,11 +1292,11 @@ function wtMarks(s) {
1244
1292
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1245
1293
  const p = node.querySelector('#wtProg'); if (p) p.textContent = (data.steps || []).filter((x) => rev.has(x.id)).length + ' of ' + (data.steps || []).length + ' reviewed'
1246
1294
  }
1247
- async function wtReviewed(sid, step) {
1295
+ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1248
1296
  const s = wtSession(sid)
1249
1297
  const data = wtUi.data.get(wtKey(sid))
1250
1298
  if (!s || !data || !data.fingerprint) return
1251
- const on = !reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && !x.on)
1299
+ const on = force != null ? force : !reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && !x.on)
1252
1300
  const x = { fp: data.fingerprint, step, on }
1253
1301
  wtUi.pending = wtUi.pending.filter((p) => !(p.fp === x.fp && p.step === step)).concat([x])
1254
1302
  wtMarks(s)
@@ -1260,7 +1308,20 @@ async function wtReviewed(sid, step) {
1260
1308
  } catch (e) { toast(e.message, 4200) }
1261
1309
  wtUi.pending = wtUi.pending.filter((p) => p !== x)
1262
1310
  wtMarks(s)
1263
- if (on) { const nx = stepAfter(data.steps || [], step, 1, wtUi.filter); if (nx && nx !== step) wtGo(sid, nx) }
1311
+ wtSizeRepaint(sid) // 2026-10-30 the sidebar's reviewed count
1312
+ if (on && advance) { const nx = stepAfter(data.steps || [], step, 1, wtUi.filter); if (nx && nx !== step) wtGo(sid, nx) }
1313
+ }
1314
+ // 0.9.19 (Joel): "Mark reviewed & next" in the slides' bar: marks the step on screen (never
1315
+ // un-marks it) and moves on; on the last step it finishes on the overview.
1316
+ function wtRevNext() {
1317
+ const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
1318
+ const slides = [...deck.children], cur = wtDeckCur()
1319
+ const st = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), cur)
1320
+ if (!st.show) return void wtSlideGo(cur + 1)
1321
+ const step = slides[cur].dataset.step, sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1322
+ 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)
1323
+ if (!done) wtReviewed(sid, step, { force: true, advance: false })
1324
+ wtSlideGo(st.to)
1264
1325
  }
1265
1326
  function wtGo(sid, step, { group = null } = {}) {
1266
1327
  const node = wtUi.node; if (!node) return
@@ -1307,6 +1368,8 @@ function wtSlideGo(i) {
1307
1368
  if (nav) {
1308
1369
  nav.querySelector('.wt-dprev').disabled = i === 0
1309
1370
  nav.querySelector('.wt-dnext').disabled = i === n - 1
1371
+ const rv = nav.querySelector('#wtDrev'), rs = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), i)
1372
+ 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 }
1310
1373
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1311
1374
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1312
1375
  ;[...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') })
@@ -1314,7 +1377,7 @@ function wtSlideGo(i) {
1314
1377
  if (sc) sc.scrollTop = wtSlideScroll.has(fp + '|' + i) ? wtSlideScroll.get(fp + '|' + i) : Math.min(sc.scrollTop, deck.offsetTop)
1315
1378
  wtSlideSet(fp, wtUi.filter, i)
1316
1379
  wtDeckPaint(node)
1317
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1380
+ wtHydrate(node, data)
1318
1381
  wtStuck(sc)
1319
1382
  return true
1320
1383
  }
@@ -1330,12 +1393,22 @@ function wtStep(dir) {
1330
1393
  // "Ask about this change…": the question (with the step's file:line) into the session's own chat box, not sent.
1331
1394
  function wtAsk(sid, text) {
1332
1395
  text = String(text || '').trim()
1333
- if (!text) return
1334
1396
  const data = wtUi.data.get(wtKey(sid))
1397
+ // 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
1398
+ const picks = wtPicksNow(data)
1399
+ if (picks.length) {
1400
+ const msg = refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text)
1401
+ wtUi.picks = []
1402
+ return wtAskPut(sid, msg)
1403
+ }
1404
+ if (!text) return
1335
1405
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1336
1406
  const co = st && st.callouts[0]
1337
1407
  const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
1338
1408
  const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
1409
+ wtAskPut(sid, msg)
1410
+ }
1411
+ function wtAskPut(sid, msg) {
1339
1412
  const k = 'live:' + sid
1340
1413
  const typed = ((state.drafts && state.drafts[k]) || '').trim()
1341
1414
  state.drafts[k] = typed ? typed + '\n\n' + msg : msg
@@ -1353,6 +1426,233 @@ function wtExport(kind) {
1353
1426
  const a = document.createElement('a'); a.href = url; a.download = exportName(title); document.body.appendChild(a); a.click(); a.remove()
1354
1427
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1355
1428
  }
1429
+ // ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
1430
+ // Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
1431
+ // number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
1432
+ // file, any step). On a phone: tap to pick, tap another line of the same snippet to stretch it.
1433
+ // The picks show as chips over the ask box (× takes one off, the chip goes to its lines) and a
1434
+ // floating "Ask about these lines" sits by the newest one.
1435
+ function wtPicksNow(data) {
1436
+ const fp = data && data.fingerprint
1437
+ if (!fp || wtUi.picksFp !== fp) { wtUi.picks = []; wtUi.picksFp = fp || '' }
1438
+ return wtUi.picks
1439
+ }
1440
+ function wtGutHit(target) {
1441
+ const g = target && target.closest && target.closest('.wt-gd .gd-gut')
1442
+ if (!g || g.classList.contains('gd-span') || !wtUi.node || !wtUi.node.contains(g)) return null
1443
+ const row = g.closest('[data-ri]'), gd = g.closest('.wt-gd')
1444
+ if (!row || !gd || !g.textContent) return null // an empty number: the other side of a split row
1445
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1446
+ const x = patchFor(data, gd.dataset.wtk)
1447
+ if (!x) return null
1448
+ return { k: gd.dataset.wtk, file: x.file, step: x.step, ri: Number(row.dataset.ri), gd }
1449
+ }
1450
+ let wtDrag = null, wtPtr = 'mouse'
1451
+ document.addEventListener('pointerdown', (ev) => {
1452
+ wtPtr = ev.pointerType || 'mouse'
1453
+ if (wtPtr !== 'mouse' || ev.button !== 0) return
1454
+ const at = wtGutHit(ev.target)
1455
+ if (!at) return
1456
+ ev.preventDefault() // no text selection while picking
1457
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1458
+ const before = wtPicksNow(data)
1459
+ wtUi.picks = pickLine(before, at, { shift: ev.shiftKey, add: ev.metaKey || ev.ctrlKey })
1460
+ const i = wtUi.picks.findIndex((p) => p.k === at.k && at.ri >= p.a && at.ri <= p.b)
1461
+ wtDrag = !ev.shiftKey && i >= 0 && wtUi.picks.length >= before.length ? { i, k: at.k } : null
1462
+ wtPicksPaint({ focusAsk: false })
1463
+ })
1464
+ document.addEventListener('pointermove', (ev) => {
1465
+ if (!wtDrag || !(ev.buttons & 1)) return
1466
+ const el = document.elementFromPoint(ev.clientX, ev.clientY)
1467
+ const row = el && el.closest && el.closest('.wt-gd[data-wtk] [data-ri]')
1468
+ if (!row || row.closest('.wt-gd').dataset.wtk !== wtDrag.k) return
1469
+ const next = dragTo(wtUi.picks, wtDrag.i, Number(row.dataset.ri))
1470
+ const p = next[wtDrag.i], q = wtUi.picks[wtDrag.i]
1471
+ if (p && q && (p.a !== q.a || p.b !== q.b)) { wtUi.picks = next; wtPicksPaint() }
1472
+ })
1473
+ document.addEventListener('pointerup', () => { wtDrag = null })
1474
+ document.addEventListener('click', (ev) => {
1475
+ if (wtPtr === 'mouse') return
1476
+ const at = wtGutHit(ev.target)
1477
+ if (!at) return
1478
+ ev.preventDefault()
1479
+ wtUi.picks = pickLine(wtPicksNow(wtUi.data.get(wtKey(wtUi.sid))), at, { touch: true })
1480
+ wtPicksPaint()
1481
+ }, true)
1482
+ // the picked rows marked (both drawings), the chips, the floating button
1483
+ function wtPicksPaint() {
1484
+ const node = wtUi.node
1485
+ if (!node) return
1486
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1487
+ const picks = wtPicksNow(data)
1488
+ for (const r of node.querySelectorAll('.wt-sel')) r.classList.remove('wt-sel', 'wt-sel-a', 'wt-sel-b')
1489
+ for (const gd of node.querySelectorAll('.wt-gd[data-wtk]')) {
1490
+ const mine = picks.filter((p) => p.k === gd.dataset.wtk)
1491
+ if (!mine.length) continue
1492
+ for (const r of gd.querySelectorAll('[data-ri]')) {
1493
+ const ri = Number(r.dataset.ri)
1494
+ const p = mine.find((x) => ri >= x.a && ri <= x.b)
1495
+ if (!p || r.classList.contains('gd-hunk') || r.classList.contains('gd-note')) continue
1496
+ r.classList.add('wt-sel')
1497
+ if (ri === p.a) r.classList.add('wt-sel-a')
1498
+ if (ri === p.b) r.classList.add('wt-sel-b')
1499
+ }
1500
+ }
1501
+ const tray = node.querySelector('#wtRefs')
1502
+ if (tray) {
1503
+ const refs = describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label } })
1504
+ tray.innerHTML = refs.map((r, i) => '<span class="wt-ref" role="listitem"><button type="button" class="wt-ref-go mono" data-action="wt-ref-go" data-i="' + i + '" data-tip="Show these lines">' + esc(r.label) + '</button><button type="button" class="wt-ref-x" data-action="wt-ref-x" data-i="' + i + '" aria-label="' + esc('Remove ' + r.label) + '" data-tip="Remove">' + icon('x') + '</button></span>').join('') +
1505
+ (refs.length > 1 ? '<button type="button" class="linkish wt-ref-clr" data-action="wt-ref-x" data-i="all">Clear</button>' : '')
1506
+ tray.hidden = !refs.length
1507
+ const inp = node.querySelector('#wtAsk')
1508
+ if (inp) inp.placeholder = refs.length ? (refs.length === 1 ? 'Ask about these lines…' : 'Ask about these ' + refs.length + ' places…') : 'Ask about this change…'
1509
+ }
1510
+ wtAskLPlace()
1511
+ }
1512
+ // the floating "Ask about these lines": under the newest pick's last row, when that row is on screen
1513
+ function wtAskLPlace() {
1514
+ const node = wtUi.node, b = node && node.querySelector('#wtAskL')
1515
+ if (!b) return
1516
+ // the newest pick that's on this slide or page
1517
+ let gd = null, row = null
1518
+ for (let i = wtUi.picks.length - 1; i >= 0 && !row; i--) {
1519
+ const p = wtUi.picks[i]
1520
+ gd = node.querySelector('.wt-gd[data-wtk="' + CSS.escape(p.k) + '"]')
1521
+ row = gd && [...gd.querySelectorAll('[data-ri="' + p.b + '"]')].find((x) => x.getClientRects().length)
1522
+ }
1523
+ const sc = node.querySelector('#wtScroll')
1524
+ const r = row && row.getBoundingClientRect(), box = sc && sc.getBoundingClientRect(), g = gd && gd.getBoundingClientRect()
1525
+ if (!r || !box || r.bottom < box.top || r.top > box.bottom - 40) { b.hidden = true; return }
1526
+ b.hidden = false
1527
+ const w = b.offsetWidth || 200
1528
+ b.style.top = Math.round(Math.min(box.bottom - 48, r.bottom + 4)) + 'px'
1529
+ b.style.left = Math.round(Math.max(box.left + 8, Math.min(g.right - w - 8, window.innerWidth - w - 8))) + 'px'
1530
+ }
1531
+ document.addEventListener('scroll', (e) => { if (wtUi.node && wtUi.picks.length && e.target && e.target.nodeType === 1 && wtUi.node.contains(e.target)) requestAnimationFrame(wtAskLPlace) }, { capture: true, passive: true })
1532
+ window.addEventListener('resize', () => { if (wtUi.node && wtUi.picks.length) requestAnimationFrame(wtAskLPlace) })
1533
+ // a drawn patch's key and a row -> on screen: the step's slide or section, its code and its file open, scrolled to
1534
+ function wtReveal(key, ri, { flash = false } = {}) {
1535
+ const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1536
+ if (!data || !wtUi.node) return null
1537
+ const k = String(key)
1538
+ if (k.startsWith('s:')) {
1539
+ const step = k.split(':')[1]
1540
+ const st = (data.steps || []).find((x) => x.id === step)
1541
+ if (wtUi.view !== 'story') { wtUi.view = 'story'; wtRemember(); wtFill(wtSession(sid)) }
1542
+ if (st && st.tag !== 'Core' && !wtUi.code.has(step) && wtDeckCur() < 0) { wtUi.code.add(step); wtFill(wtSession(sid)) }
1543
+ wtGo(sid, step)
1544
+ const slot = wtUi.node.querySelector('[data-snip="' + CSS.escape(k.slice(2)) + '"]')
1545
+ const d = slot && slot.closest('details.wt-sf')
1546
+ if (d && !d.open) { d.open = true; sfSet(data.fingerprint, [d.dataset.sfk], true); wtHydrate(wtUi.node, data) }
1547
+ } else if (k.startsWith('f:')) {
1548
+ if (wtUi.view !== 'files') { wtUi.view = 'files'; wtRemember(); wtFill(wtSession(sid)) }
1549
+ const i = (data.files || []).findIndex((f) => f.path === k.slice(2))
1550
+ const d = wtUi.node.querySelector('details.wt-file[data-file-i="' + i + '"]')
1551
+ if (d && !d.open) { d.open = true; wtHydrate(wtUi.node, data) }
1552
+ }
1553
+ wtPicksPaint()
1554
+ const gd = wtUi.node.querySelector('.wt-gd[data-wtk="' + CSS.escape(k) + '"]')
1555
+ const row = gd && gd.querySelector('[data-ri="' + ri + '"]')
1556
+ if (row) row.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth' })
1557
+ return row
1558
+ }
1559
+ // Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
1560
+ // (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
1561
+ function wtSpots(data) { return data && data.status === 'ok' ? spotsFor(data, readingOrder(data.steps, data)) : [] }
1562
+ function wtSpotGo(dir = 1) {
1563
+ const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1564
+ if (!data || data.status !== 'ok' || !wtUi.node) return
1565
+ const spots = wtSpots(data)
1566
+ if (!spots.length) return
1567
+ const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1568
+ const i = nextSpotIndex(spots.length, cur, dir)
1569
+ wtUi.spot.set(data.fingerprint, i)
1570
+ const sp = spots[i]
1571
+ const st = (data.steps || []).find((x) => x.id === sp.step)
1572
+ const sn = st && st.snippets[sp.snip]
1573
+ const rows = sn ? rowsOf(sn.patch) : []
1574
+ const ri = rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1575
+ for (const x of wtUi.node.querySelectorAll('.wt-spot-on')) x.classList.remove('wt-spot-on')
1576
+ for (const x of wtUi.node.querySelectorAll('.wt-spot-tag')) x.remove()
1577
+ const row = wtReveal('s:' + sp.step + ':' + sp.snip, ri)
1578
+ // in the split drawing a removed line is the left half, an added one the right
1579
+ const gd = row && row.closest('.wt-gd')
1580
+ const cands = gd ? [...gd.querySelectorAll('[data-ri="' + ri + '"]')] : []
1581
+ const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0] || null
1582
+ if (at) {
1583
+ at.classList.add('wt-spot-on')
1584
+ const tag = document.createElement('span')
1585
+ tag.className = 'wt-spot-tag'
1586
+ tag.setAttribute('role', 'note')
1587
+ tag.innerHTML = icon('crosshair') + '<span></span>'
1588
+ tag.lastChild.textContent = sp.why || 'Worth a look'
1589
+ at.appendChild(tag)
1590
+ }
1591
+ const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1592
+ const b = wtUi.node.querySelector('.wt-spot'); if (b) b.setAttribute('aria-label', 'Next spot to look at, ' + spotCountWords(spots.length, i))
1593
+ }
1594
+ // Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
1595
+ // only on a screen 1000px or wider, else unified (the toggle is hidden there)
1596
+ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN + 'px)') : null
1597
+ function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
1598
+ function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
1599
+ if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
1600
+ function wtHydrate(node, data) { return hydrateWalkthrough(node, data, { renderDiff, renderSplit, split: wtSplitOn(), esc, icon }) }
1601
+ // the file list folds to a rail ([ or its button) and its edge drags; both kept per device
1602
+ function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
1603
+ function wtListUp() {
1604
+ const p = wtUi.sid && wtUi.node && wtUi.node.isConnected && wtUi.node.querySelector('#wt2p')
1605
+ if (!p || !p.getClientRects().length) return null
1606
+ const shown = (el) => el && getComputedStyle(el).display !== 'none'
1607
+ return shown(p.querySelector(':scope > .wt-side')) || shown(p.querySelector(':scope > .wt-rail')) ? p : null
1608
+ }
1609
+ function wtRail() {
1610
+ const p = wtListUp()
1611
+ if (!p) return
1612
+ const on = !p.classList.contains('rail')
1613
+ p.classList.toggle('rail', on)
1614
+ try { if (on) localStorage.setItem('lo-wt-rail', '1'); else localStorage.removeItem('lo-wt-rail') } catch {}
1615
+ const b = p.querySelector(on ? '.wt-rail .wt-railt' : '.wt-side .wt-railt')
1616
+ if (b && document.activeElement && document.activeElement.closest && document.activeElement.closest('.wt-railt')) b.focus({ preventScroll: true })
1617
+ }
1618
+ const clampW = (w) => Math.max(WT_SIDE_MIN, Math.min(WT_SIDE_MAX, Math.round(w)))
1619
+ function wtSideW() { try { const w = Number(localStorage.getItem('lo-wt-side-w')); return w ? clampW(w) : WT_SIDE_DEFAULT } catch { return WT_SIDE_DEFAULT } }
1620
+ function wtSideWidth(node, w = wtSideW()) {
1621
+ const p = node && node.querySelector('#wt2p')
1622
+ if (p) p.style.setProperty('--wt-side-w', w + 'px')
1623
+ const rz = p && p.querySelector('.wt-rz')
1624
+ if (rz) { rz.setAttribute('aria-valuemin', WT_SIDE_MIN); rz.setAttribute('aria-valuemax', WT_SIDE_MAX); rz.setAttribute('aria-valuenow', w) }
1625
+ }
1626
+ let wtRz = null
1627
+ document.addEventListener('pointerdown', (ev) => {
1628
+ const rz = ev.target && ev.target.closest && ev.target.closest('#wt2p > .wt-rz')
1629
+ if (!rz || ev.button !== 0) return
1630
+ ev.preventDefault()
1631
+ const side = rz.parentNode.querySelector('.wt-side')
1632
+ wtRz = { left: side.getBoundingClientRect().left, id: ev.pointerId, rz }
1633
+ try { rz.setPointerCapture(ev.pointerId) } catch {}
1634
+ rz.classList.add('on')
1635
+ })
1636
+ document.addEventListener('pointermove', (ev) => {
1637
+ if (!wtRz) return
1638
+ wtSideWidth(wtUi.node, clampW(ev.clientX - wtRz.left))
1639
+ })
1640
+ document.addEventListener('pointerup', () => {
1641
+ if (!wtRz) return
1642
+ const p = wtUi.node && wtUi.node.querySelector('#wt2p')
1643
+ const w = p ? parseInt(p.style.getPropertyValue('--wt-side-w'), 10) : 0
1644
+ if (w) try { localStorage.setItem('lo-wt-side-w', String(w)) } catch {}
1645
+ wtRz.rz.classList.remove('on')
1646
+ wtRz = null
1647
+ })
1648
+ document.addEventListener('keydown', (ev) => {
1649
+ const rz = ev.target && ev.target.closest && ev.target.closest('#wt2p > .wt-rz')
1650
+ if (!rz || (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight')) return
1651
+ ev.preventDefault()
1652
+ const w = clampW(wtSideW() + (ev.key === 'ArrowRight' ? 16 : -16))
1653
+ try { localStorage.setItem('lo-wt-side-w', String(w)) } catch {}
1654
+ wtSideWidth(wtUi.node, w)
1655
+ })
1356
1656
  document.addEventListener('submit', (ev) => {
1357
1657
  const f = ev.target && ev.target.closest && ev.target.closest('form.wt-ask')
1358
1658
  if (!f) return
@@ -1370,7 +1670,8 @@ document.addEventListener('toggle', (ev) => {
1370
1670
  const d = ev.target
1371
1671
  if (!d || !d.matches || !wtUi.node || !wtUi.node.contains(d)) return
1372
1672
  if (d.matches('details.wt-grp')) { if (d.open) wtUi.groups.add(d.dataset.group); else wtUi.groups.delete(d.dataset.group) }
1373
- if (d.open) hydrateWalkthrough(wtUi.node, wtUi.data.get(wtKey(wtUi.sid)), { renderDiff, esc, icon })
1673
+ if (d.matches('details.wt-sf')) { const data = wtUi.data.get(wtKey(wtUi.sid)); if (data && data.fingerprint) sfSet(data.fingerprint, [d.dataset.sfk], d.open) } // 2026-10-30 a step's file, folded or open, kept per walkthrough
1674
+ if (d.open) wtHydrate(wtUi.node, wtUi.data.get(wtKey(wtUi.sid)))
1374
1675
  }, true)
1375
1676
  // j / k between steps (not while typing)
1376
1677
  document.addEventListener('keydown', (ev) => {
@@ -1378,12 +1679,16 @@ document.addEventListener('keydown', (ev) => {
1378
1679
  const t = ev.target
1379
1680
  if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return
1380
1681
  if (!document.getElementById('wtMount')) return
1682
+ // 2026-10-30 n / Shift+N: the next (previous) spot worth a real look; [ folds the file list
1683
+ if (ev.key === 'n' || ev.key === 'N') { ev.preventDefault(); return void wtSpotGo(ev.key === 'N' ? -1 : 1) }
1684
+ if (ev.key === '[') { ev.preventDefault(); return void wtRail() }
1381
1685
  // 0.9.18 the slideshow: ← / → and j / k move, ↑ / ↓ scroll the slide, Home / End jump, Space pages
1382
1686
  // (a focused button keeps its own Space and arrows; nothing is taken from a text box)
1383
1687
  if (wtDeckCur() >= 0 && (!t || t === document.body || (wtUi.node && wtUi.node.contains(t)))) {
1384
1688
  const sc = wtUi.node.querySelector('#wtScroll'), onCtl = t && t.closest && t.closest('button, a, summary, [role="radio"]')
1385
1689
  const go = (i) => { ev.preventDefault(); wtSlideGo(i) }
1386
1690
  const k = ev.key, cur = wtDeckCur()
1691
+ if ((k === 'ArrowRight' && ev.shiftKey) || (k === 'Enter' && !onCtl)) { ev.preventDefault(); return void wtRevNext() } // 0.9.19 mark reviewed & next
1387
1692
  if (k === 'ArrowRight' || k === 'j') return go(cur + 1)
1388
1693
  if (k === 'ArrowLeft' || k === 'k') return go(cur - 1)
1389
1694
  if (k === 'Home' && !onCtl) return go(0)
@@ -2068,7 +2373,7 @@ async function viewWaiting() {
2068
2373
  (recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
2069
2374
  (r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
2070
2375
  '<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
2071
- (r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
2376
+ (r.where ? '<span class="where">' + esc(whereWords(r.where)) + '</span>' : '') + '</li>').join('') + '</ul>'
2072
2377
  : '<div class="nocp">Nothing yet since the daemon started.</div>') +
2073
2378
  '<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
2074
2379
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
@@ -2150,13 +2455,10 @@ document.addEventListener('keydown', (ev) => {
2150
2455
  const b = document.querySelector('[data-action="agent-add"]')
2151
2456
  if (b && !b.disabled) b.click()
2152
2457
  })
2458
+ // 2026-10-29: the newest three, then Show all N; where each came from in true words (ui/side-fold.js)
2153
2459
  function answeredList(s) {
2154
- const list = (s.answers || []).slice().reverse()
2155
- if (!list.length) return ''
2156
- return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
2157
- '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
2158
- '<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
2159
- '<div class="hint">Also in answers.md in this session’s folder.</div>'
2460
+ const body = answeredHtml(s.answers, { clock }, { esc })
2461
+ return body ? '<section class="sanswered" id="answered">' + body + '</section>' : ''
2160
2462
  }
2161
2463
 
2162
2464
  // External agents (v0.5.4): a feed session's page. Everything is the feed's
@@ -2550,9 +2852,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2550
2852
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2551
2853
  '<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
2552
2854
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2553
- '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div>' +
2855
+ '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
2856
+ // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2554
2857
  (where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
2555
- '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>' + '</div>'
2858
+ '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
2556
2859
  // the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
2557
2860
  noteOpenAway(s)
2558
2861
  const awayMs = openAway.get(s.id)
@@ -2562,7 +2865,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2562
2865
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2563
2866
  const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2564
2867
  const wtOn = wtUi.sid === s.id
2565
- const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
2868
+ const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id), reviewed: wtRevCount(s, wtSizeOf(s.id)) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
2566
2869
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
2567
2870
  const acts = '<div class="fs-acts' + (wtBtn ? ' fs-acts-cu' : '') + '">' + cuBtn + (wtBtn || (mine ? checkpointSplit(s, { quiet: away }) : '')) + '</div>' + (wtBtn && mine ? '<div class="fs-acts fs-acts-cp">' + checkpointSplit(s, { quiet: true }) + '</div>' : '')
2568
2871
  const acts2 = '<div class="fs-acts2">' + (s.archived && mine ? unarchiveButtonHtml(s) : '') + goButton(s, { page: true }) + askButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '</div>'
@@ -2576,33 +2879,44 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2576
2879
  const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
2577
2880
  const wr = writingFor(s) // 0.9.5: the one being written, at the top until it lands
2578
2881
  const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
2579
- const cps = s.checkpoints.filter((cp) => !cp.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
2580
- side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
2581
- side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + cpTimeline(cps, newestCp, s.agent ? null : s.id) + '</div>'
2882
+ // 2026-10-29: a wrapper checkpoint ("Working on: <task-notification>…") written beside a real one isn't shown
2883
+ const cps = dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
2884
+ const paths = { roots: rootsOf(s), home: (state.health && state.health.home) || '' }
2885
+ // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2886
+ side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2887
+ 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>'
2888
+ 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 })
2582
2889
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2583
2890
  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>'
2584
2891
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
2585
2892
  if (empties.length) side += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
2586
2893
  side += '</section>'
2894
+ // 2026-10-29 a calmer panel: PRs and To do stay open; the rest is a one-line header with a count, closed
2895
+ // until you open it (kept per device: ui/side-fold.js). Each keeps its id, so its painter still finds it.
2896
+ side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
2897
+ side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
2587
2898
  // 0.9.8: the last tools it ran and its folder's changes; loads when you open it
2588
2899
  side += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
2900
+ const fold = (key, title, count, body) => foldHtml({ key, title, count, body, open: sideFolds.open(key) }, { esc })
2901
+ const fl = s.files || {}, nFiles = (fl.artifacts || []).length + (fl.plans || []).length + (fl.docs || []).length
2902
+ const filesBody = sessionFilesSection(s, { agoFn: ago })
2903
+ side += filesBody ? fold('files', 'Files', nFiles || null, '<section class="sfiles" id="files">' + filesBody + '</section>') : '<section class="sfiles" id="files"></section>'
2904
+ if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
2905
+ state.listsMayEdit = state.mayAnswer !== false
2906
+ const listsBody = sessionListsSection(s)
2907
+ side += listsBody ? fold('lists', 'Lists', (s.lists || []).length || null, '<section class="slists" id="lists">' + listsBody + '</section>') : '<section class="slists" id="lists"></section>'
2908
+ side += fold('park', s.parked ? 'Parked' : 'Park', null, parkRow(s))
2909
+ side += fold('space', 'Space', s.space || null, spacePicker(s, spv, 'session', s.id))
2910
+ side += fold('status', 'Status history', (s.statusChanges || []).length || null, statusStripHtml(s.statusChanges, { label: LABEL, clock }, { esc }))
2911
+ side += fold('answered', 'Answered from the board', (s.answers || []).length || null, answeredList(s))
2589
2912
  side += '<section class="fs-sec fs-details" id="fsDetails">' + detailsHtml( // 0.9.11: the facts, behind Details
2590
2913
  fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Started', clock(s.startedAt)) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + fsOtherRepos(s) + repoTile(s) + branchTileHtml(branch, { esc }) + fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) +
2591
2914
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
2592
2915
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
2593
2916
  (s.headless && !s.terminal && !s.tty ? fact('Terminal', 'None · started from the board', '') : fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' })), { open: dOpen }) + '</section>'
2594
- side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
2595
- side += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
2596
- side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
2597
- if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
2598
- state.listsMayEdit = state.mayAnswer !== false
2599
- side += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
2600
- side += parkRow(s) + spacePicker(s, spv, 'session', s.id)
2601
- if (s.statusChanges && s.statusChanges.length) side += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" data-tip="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
2602
- side += answeredList(s)
2603
2917
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2604
2918
  const label = fsPeekWords(s)
2605
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2919
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, scrolled: fsScrolled === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2606
2920
  }
2607
2921
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2608
2922
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2611,7 +2925,7 @@ function fsStubHtml(s, inner, foot = '') {
2611
2925
  return liveOpenHtml(s, null, '') + '<div class="live-thread fs-none" data-sid="' + esc(s.id) + '"><div class="fs-none-in">' + inner + '</div></div>' + foot + '</section>' // 0.9.17: an ended one keeps its Ended · Resume bar
2612
2926
  }
2613
2927
  function fsPeekWords(s) {
2614
- const n = s.checkpointCount || 0
2928
+ const n = Array.isArray(s.checkpoints) ? dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).length : s.checkpointCount || 0 // 2026-10-29: the junk twin isn't counted
2615
2929
  const w = String(LABEL[s.status] || s.status || 'Session')
2616
2930
  return w.charAt(0).toUpperCase() + w.slice(1) + ' · ' + n + ' checkpoint' + (n === 1 ? '' : 's')
2617
2931
  }
@@ -2986,7 +3300,24 @@ function fsRailGo(go) {
2986
3300
  if (el) el.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
2987
3301
  const d = el && el.querySelector('details:not([open])')
2988
3302
  if (d && go === 'fsDetails') d.open = true
2989
- }
3303
+ const f = el && el.closest('details.fs-fold:not([open])') // 2026-10-29: a folded section opens when the rail goes to it
3304
+ if (f) f.open = true
3305
+ }
3306
+ // 2026-10-29 the panel's folds: open or closed as you leave them, on this device (ui/side-fold.js)
3307
+ document.addEventListener('toggle', (e) => {
3308
+ const d = e.target
3309
+ if (!d || !d.matches || !d.matches('#fsSide details[data-fold]')) return
3310
+ sideFolds.set(d.dataset.fold, d.open)
3311
+ }, true)
3312
+ // The pinned head's title goes to one line once the panel scrolls, so the pinned part stays slim.
3313
+ document.addEventListener('scroll', (e) => {
3314
+ const el = e.target
3315
+ if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3316
+ const side = el.closest('.fs-side')
3317
+ fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3318
+ if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3319
+ }, true)
3320
+
2990
3321
  function toggleFsSheet() {
2991
3322
  const side = document.getElementById('fsSide')
2992
3323
  if (!side) return
@@ -4044,16 +4375,29 @@ async function aroundWire(id) {
4044
4375
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4045
4376
  }
4046
4377
  }
4047
- function cpTimeline(list, newestCp, wtSid = null) {
4378
+ function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4048
4379
  return list.map((cp) =>
4049
- '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4050
- (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>' +
4051
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
4380
+ '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4381
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4052
4382
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4053
- (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
4383
+ filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4054
4384
  (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
4055
4385
  '</div>').join('')
4056
4386
  }
4387
+ function cpHeadLine(cp) {
4388
+ return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4389
+ (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4390
+ }
4391
+ // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4392
+ // your note and the files behind More (open or closed as you last left it on this device).
4393
+ function cpLatestHtml(cp, wtSid, paths) {
4394
+ const more = cpBlock(cp, { md: true, part: 'head', paths }) + (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') + filesTouchedHtml(cp.filesTouched, paths || {}, { esc })
4395
+ return '<div class="tl-item tl-latest ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4396
+ cpBlock(cp, { showWhen: false, md: true, part: 'tail', paths }) +
4397
+ (more ? '<details class="cp-more fs-cp-more" data-fold="cp-more"' + (sideFolds.open('cp-more') ? ' open' : '') + '><summary>More</summary>' + more + '</details>' : '') +
4398
+ (wtSid ? '<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>' : '') +
4399
+ '</div>'
4400
+ }
4057
4401
  // Space of one session (or, on an agent's page, of that agent's feed): its
4058
4402
  // own override, or back to the repo / Mac default (v0.5.8).
4059
4403
  function spacePicker(s, spv, kind, key) {
@@ -7462,7 +7806,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7462
7806
  clearTimeout(timer); cur = el
7463
7807
  timer = setTimeout(() => {
7464
7808
  if (cur !== el || !el.isConnected) return hide()
7465
- if (el.matches('.wt-st-h h3') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
7809
+ if (el.matches('.wt-st-h h3, .pr-compact .pr-title-btn') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
7466
7810
  tip.textContent = el.dataset.tip
7467
7811
  const r = el.getBoundingClientRect(), w = tip.offsetWidth
7468
7812
  tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
@@ -7470,7 +7814,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7470
7814
  tip.classList.add('on')
7471
7815
  }, delay)
7472
7816
  }
7473
- 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]') // 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
7817
+ 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
7474
7818
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7475
7819
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7476
7820
  document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
@@ -8133,6 +8477,7 @@ document.addEventListener('click', async (ev) => {
8133
8477
  if (action === 'wt-code') { const st = btn.dataset.step; if (wtUi.code.has(st)) wtUi.code.delete(st); else wtUi.code.add(st); return wtFill(wtSession(wtUi.sid)) }
8134
8478
  if (action === 'wt-slide') { ev.preventDefault(); return void wtSlideGo(Number(btn.dataset.slide) || 0) } // 0.9.18
8135
8479
  if (action === 'wt-dstep') return void wtSlideGo(wtDeckCur() + (Number(btn.dataset.dir) || 1))
8480
+ if (action === 'wt-revnext') return void wtRevNext()
8136
8481
  if (action === 'wt-deck') { try { if (wtDeckOn()) localStorage.setItem('lo-wt-all', '1'); else localStorage.removeItem('lo-wt-all') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
8137
8482
  if (action === 'wt-fm-dir') { // 0.9.18 a folder in the file map folds; kept per walkthrough
8138
8483
  const k = btn.parentElement && btn.parentElement.querySelector(':scope > .wt-fm-dk')
@@ -8158,6 +8503,22 @@ document.addEventListener('click', async (ev) => {
8158
8503
  }
8159
8504
  if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
8160
8505
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8506
+ // 2026-10-30 walkthrough round 3
8507
+ if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
8508
+ if (action === 'wt-rail') return void wtRail()
8509
+ if (action === 'wt-spot') return void wtSpotGo(1)
8510
+ if (action === 'wt-update') { btn.disabled = true; return wtLoad(btn.dataset.id || wtUi.sid, { update: true }) }
8511
+ if (action === 'wt-sf-all') {
8512
+ const art = btn.closest('.wt-st'); const data = wtUi.data.get(wtKey(wtUi.sid)); if (!art || !data) return
8513
+ const open = btn.dataset.open === '1', ds = [...art.querySelectorAll('details.wt-sf')]
8514
+ for (const d of ds) d.open = open
8515
+ sfSet(data.fingerprint, ds.map((d) => d.dataset.sfk), open)
8516
+ if (open) wtHydrate(wtUi.node, data)
8517
+ return
8518
+ }
8519
+ if (action === 'wt-ref-x') { if (btn.dataset.i === 'all') wtUi.picks = []; else wtUi.picks = wtUi.picks.filter((_, i) => i !== Number(btn.dataset.i)); return void wtPicksPaint() }
8520
+ if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
8521
+ if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
8161
8522
  if (action === 'wt-export-html') return wtExport('html')
8162
8523
  if (action === 'wt-export-md') return wtExport('md')
8163
8524
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
@@ -9175,8 +9536,34 @@ async function openToolOutput(sessionId, toolId, label) {
9175
9536
  // daemon serves only the session's folder and Claude's scratch folders
9176
9537
  // (src/file-ref.js). Centered on a desktop, a bottom sheet on a phone.
9177
9538
  const refUi = { sid: null, path: null, line: null, r: null, from: null, seq: 0 }
9539
+ // 2026-10-29 Which session a path in the page belongs to, and whether this board can read its files
9540
+ // (this machine; another over the peer link; the hosted board straight from it): ui/file-reach.js.
9541
+ function refSidOf(el) {
9542
+ if (el.closest('#docViewer')) return refUi.sid
9543
+ const box = el.closest('[data-sid], #live[data-id]')
9544
+ return (box && (box.dataset.sid || box.dataset.id)) || sessionOfHash(location.hash) || liveOnScreen()
9545
+ }
9546
+ function refReachFor(el) {
9547
+ const sid = refSidOf(el)
9548
+ const s = sid && ((state.sessions || []).find((x) => x.id === sid) || (state.session && state.session.id === sid ? state.session : null))
9549
+ if (!s) return { ok: true }
9550
+ return fileReach(s, { relay: window.leftoffRelay && state.relayStatus ? state.relayStatus : null })
9551
+ }
9552
+ {
9553
+ let queued = false
9554
+ const pass = () => { queued = false; try { gateFileRefs(document.body, refReachFor) } catch {} }
9555
+ const mo = new MutationObserver((recs) => {
9556
+ if (queued) return
9557
+ if (!recs.some((r) => r.addedNodes.length)) return
9558
+ queued = true; requestAnimationFrame(pass)
9559
+ })
9560
+ if (document.body) mo.observe(document.body, { childList: true, subtree: true })
9561
+ // a path that can't be read: a tap says why (a phone has no hover tip)
9562
+ document.addEventListener('click', (e) => { const off = e.target.closest && e.target.closest('.file-ref-off'); if (off && matchMedia('(hover: none)').matches) toast(off.dataset.tip, 4000) })
9563
+ }
9178
9564
  function openRefFile(btn, cardId) {
9179
9565
  const inViewer = btn.closest('#docViewer')
9566
+ { const r = inViewer ? { ok: true } : refReachFor(btn); if (!r.ok) { toast(r.tip, 4000); return } } // never a viewer that opens onto "not available"
9180
9567
  let p = btn.dataset.path || ''
9181
9568
  if (inViewer && refUi.r && refUi.r.path && /^\.{1,2}\//.test(p)) p = refAgainst(p, refUi.r.path) // ./ or ../ inside a viewed file: that file's folder (else the session's)
9182
9569
  const sid = (inViewer && refUi.sid) || cardId || sessionOfHash(location.hash) || liveOnScreen()