@leftoff/cli 0.9.17-beta.9 → 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
@@ -54,7 +54,9 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
54
54
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
55
55
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
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 } from './semdiff.js' // 0.9.17 Walk me through it
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
58
60
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
59
61
  import { lastReply, ghostShows } from './ghost.js'
60
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
@@ -66,7 +68,7 @@ import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Cla
66
68
  import { codeCopyClick } from './code-copy.js' // 0.9.15: Copy on code blocks
67
69
  import { refBody, refErrorBody, refBarHtml, sizeLabel as refSize } from './file-view.js' // 0.9.15: open a file from a message
68
70
  import { refAgainst } from './file-links.js'
69
- 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
70
72
  import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLogin, offerDismissed as loginOfferOff, dismissOffer as loginDismissOffer } from './login-banner.js' // 0.9.14 Claude login expiry
71
73
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
72
74
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
@@ -250,6 +252,7 @@ document.addEventListener('keydown', (e) => {
250
252
  if (!isNavKey(e, { typing, term, mac: navMac }) || !navRailQ.matches) return
251
253
  if (document.querySelector('.lo-menu, .cmdk:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden])')) return
252
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)
253
256
  e.preventDefault()
254
257
  toggleNav()
255
258
  }, true)
@@ -284,7 +287,7 @@ const $app = document.getElementById('app')
284
287
  const scrollKeep = createScrollKeep()
285
288
  scrollKeep.start()
286
289
  // 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
287
- 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
288
291
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
289
292
  if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
290
293
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -1120,8 +1123,26 @@ async function wtOpen(sid, scope = null) {
1120
1123
  if (!isFullLive(sid)) { location.hash = fullHref(sid); return }
1121
1124
  if (phoneNow() && fsSheetUi.open === sid) fsSheetUi.open = null
1122
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
1123
1128
  wtLoad(sid)
1124
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
+ }
1125
1146
  function wtClose() {
1126
1147
  const sid = wtUi.sid
1127
1148
  document.documentElement.classList.remove('wt-open')
@@ -1147,30 +1168,47 @@ async function wtSizeFetch(sid) {
1147
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
1148
1169
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1149
1170
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1150
- slot.innerHTML = sizeLineHtml(data, { esc, icon })
1171
+ slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
1151
1172
  const b = slot.closest('.fs-wt'); if (b) b.classList.toggle('none', !!(data && data.files === 0))
1152
1173
  }
1153
1174
  if (loPop) loPop.refresh('wt', sid)
1154
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
+ }
1155
1188
  let loPop = null
1156
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)
1157
1190
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1158
- 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 } = {}) {
1159
1194
  const k = wtKey(sid)
1160
1195
  const had = wtUi.data.get(k)
1161
- if (!fresh && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1162
- 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))
1163
1198
  wtUi.loading.add(k); wtUi.error.delete(k)
1164
1199
  wtFill(wtSession(sid))
1165
1200
  try {
1166
- 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
1167
1202
  wtUi.data.set(k, r)
1168
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 }
1169
1206
  if (r && r.status === 'no_range' && wtUi.scope.get(sid) === 'checkpoint') { wtUi.scope.set(sid, 'session'); wtUi.loading.delete(k); return wtLoad(sid) }
1170
1207
  if (r && r.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); wtUi.loading.delete(k); return wtLoad(sid) }
1171
1208
  } catch (e) { wtUi.error.set(k, (e && e.message) || 'Couldn’t read the change.') }
1172
1209
  wtUi.loading.delete(k)
1173
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
1174
1212
  }
1175
1213
  // The node for the page's #wtMount: built when what it shows changed, else moved back in.
1176
1214
  function wtFill(s) {
@@ -1181,8 +1219,8 @@ function wtFill(s) {
1181
1219
  const fp = data && data.fingerprint
1182
1220
  const rev = reviewedSet(s, fp)
1183
1221
  for (const x of wtUi.pending) if (x.fp === fp) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1184
- 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) }
1185
- 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 : ''])
1186
1224
  let node = wtUi.node
1187
1225
  if (!node || wtUi.sig !== sig) {
1188
1226
  // (the old node may be out of the page already, reading 0: what was recorded as you scrolled wins)
@@ -1196,9 +1234,10 @@ function wtFill(s) {
1196
1234
  node.innerHTML = walkthroughHtml({ s, data, ui, reviewed: rev, esc, icon, when })
1197
1235
  wtUi.node = node; wtUi.sig = sig
1198
1236
  mount.replaceChildren(node)
1199
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1237
+ wtHydrate(node, data)
1200
1238
  wtStuck(document.querySelector('.wt-scroll')) // 0.9.18
1201
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
1202
1241
  const sc = node.querySelector('#wtScroll'); if (sc && keep) sc.scrollTop = keep
1203
1242
  const sd = node.querySelector('.wt-side'); if (sd && keepSide) sd.scrollTop = keepSide
1204
1243
  } else {
@@ -1211,6 +1250,7 @@ function wtFill(s) {
1211
1250
  }
1212
1251
  wtMarks(s)
1213
1252
  }
1253
+ wtPicksPaint() // 2026-10-30 picked lines, their chips, the floating Ask
1214
1254
  if (!data && !ui.loading && !ui.error) setTimeout(() => wtLoad(s.id), 0) // opened by a reload: read it now
1215
1255
  }
1216
1256
  // 0.9.18 where each scroll box in the walkthrough was (the slide or long page, the file list, the
@@ -1252,11 +1292,11 @@ function wtMarks(s) {
1252
1292
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1253
1293
  const p = node.querySelector('#wtProg'); if (p) p.textContent = (data.steps || []).filter((x) => rev.has(x.id)).length + ' of ' + (data.steps || []).length + ' reviewed'
1254
1294
  }
1255
- async function wtReviewed(sid, step) {
1295
+ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1256
1296
  const s = wtSession(sid)
1257
1297
  const data = wtUi.data.get(wtKey(sid))
1258
1298
  if (!s || !data || !data.fingerprint) return
1259
- 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)
1260
1300
  const x = { fp: data.fingerprint, step, on }
1261
1301
  wtUi.pending = wtUi.pending.filter((p) => !(p.fp === x.fp && p.step === step)).concat([x])
1262
1302
  wtMarks(s)
@@ -1268,7 +1308,20 @@ async function wtReviewed(sid, step) {
1268
1308
  } catch (e) { toast(e.message, 4200) }
1269
1309
  wtUi.pending = wtUi.pending.filter((p) => p !== x)
1270
1310
  wtMarks(s)
1271
- 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)
1272
1325
  }
1273
1326
  function wtGo(sid, step, { group = null } = {}) {
1274
1327
  const node = wtUi.node; if (!node) return
@@ -1315,6 +1368,8 @@ function wtSlideGo(i) {
1315
1368
  if (nav) {
1316
1369
  nav.querySelector('.wt-dprev').disabled = i === 0
1317
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 }
1318
1373
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1319
1374
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1320
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') })
@@ -1322,7 +1377,7 @@ function wtSlideGo(i) {
1322
1377
  if (sc) sc.scrollTop = wtSlideScroll.has(fp + '|' + i) ? wtSlideScroll.get(fp + '|' + i) : Math.min(sc.scrollTop, deck.offsetTop)
1323
1378
  wtSlideSet(fp, wtUi.filter, i)
1324
1379
  wtDeckPaint(node)
1325
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1380
+ wtHydrate(node, data)
1326
1381
  wtStuck(sc)
1327
1382
  return true
1328
1383
  }
@@ -1338,12 +1393,22 @@ function wtStep(dir) {
1338
1393
  // "Ask about this change…": the question (with the step's file:line) into the session's own chat box, not sent.
1339
1394
  function wtAsk(sid, text) {
1340
1395
  text = String(text || '').trim()
1341
- if (!text) return
1342
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
1343
1405
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1344
1406
  const co = st && st.callouts[0]
1345
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(/:$/, '') : ''
1346
1408
  const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
1409
+ wtAskPut(sid, msg)
1410
+ }
1411
+ function wtAskPut(sid, msg) {
1347
1412
  const k = 'live:' + sid
1348
1413
  const typed = ((state.drafts && state.drafts[k]) || '').trim()
1349
1414
  state.drafts[k] = typed ? typed + '\n\n' + msg : msg
@@ -1361,6 +1426,233 @@ function wtExport(kind) {
1361
1426
  const a = document.createElement('a'); a.href = url; a.download = exportName(title); document.body.appendChild(a); a.click(); a.remove()
1362
1427
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1363
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
+ })
1364
1656
  document.addEventListener('submit', (ev) => {
1365
1657
  const f = ev.target && ev.target.closest && ev.target.closest('form.wt-ask')
1366
1658
  if (!f) return
@@ -1378,7 +1670,8 @@ document.addEventListener('toggle', (ev) => {
1378
1670
  const d = ev.target
1379
1671
  if (!d || !d.matches || !wtUi.node || !wtUi.node.contains(d)) return
1380
1672
  if (d.matches('details.wt-grp')) { if (d.open) wtUi.groups.add(d.dataset.group); else wtUi.groups.delete(d.dataset.group) }
1381
- 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)))
1382
1675
  }, true)
1383
1676
  // j / k between steps (not while typing)
1384
1677
  document.addEventListener('keydown', (ev) => {
@@ -1386,12 +1679,16 @@ document.addEventListener('keydown', (ev) => {
1386
1679
  const t = ev.target
1387
1680
  if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return
1388
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() }
1389
1685
  // 0.9.18 the slideshow: ← / → and j / k move, ↑ / ↓ scroll the slide, Home / End jump, Space pages
1390
1686
  // (a focused button keeps its own Space and arrows; nothing is taken from a text box)
1391
1687
  if (wtDeckCur() >= 0 && (!t || t === document.body || (wtUi.node && wtUi.node.contains(t)))) {
1392
1688
  const sc = wtUi.node.querySelector('#wtScroll'), onCtl = t && t.closest && t.closest('button, a, summary, [role="radio"]')
1393
1689
  const go = (i) => { ev.preventDefault(); wtSlideGo(i) }
1394
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
1395
1692
  if (k === 'ArrowRight' || k === 'j') return go(cur + 1)
1396
1693
  if (k === 'ArrowLeft' || k === 'k') return go(cur - 1)
1397
1694
  if (k === 'Home' && !onCtl) return go(0)
@@ -2568,7 +2865,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2568
2865
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2569
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>') : ''
2570
2867
  const wtOn = wtUi.sid === s.id
2571
- 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)
2572
2869
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
2573
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>' : '')
2574
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>'
@@ -8180,6 +8477,7 @@ document.addEventListener('click', async (ev) => {
8180
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)) }
8181
8478
  if (action === 'wt-slide') { ev.preventDefault(); return void wtSlideGo(Number(btn.dataset.slide) || 0) } // 0.9.18
8182
8479
  if (action === 'wt-dstep') return void wtSlideGo(wtDeckCur() + (Number(btn.dataset.dir) || 1))
8480
+ if (action === 'wt-revnext') return void wtRevNext()
8183
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)) }
8184
8482
  if (action === 'wt-fm-dir') { // 0.9.18 a folder in the file map folds; kept per walkthrough
8185
8483
  const k = btn.parentElement && btn.parentElement.querySelector(':scope > .wt-fm-dk')
@@ -8205,6 +8503,22 @@ document.addEventListener('click', async (ev) => {
8205
8503
  }
8206
8504
  if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
8207
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 }
8208
8522
  if (action === 'wt-export-html') return wtExport('html')
8209
8523
  if (action === 'wt-export-md') return wtExport('md')
8210
8524
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
package/ui/back-line.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // × hides that one line for this tab (sessionStorage); a new recap or
6
6
  // checkpoint brings it back. Pure apart from the store the board passes.
7
7
 
8
+ import { mdInline } from './changelog-md.js'
9
+ // 0.9.19: the words are Claude's or the summarizer's, so `code` and **bold** render (escaped first, no links)
10
+ const md = (x) => mdInline(x, { links: false })
8
11
  const MIN = 60000
9
12
  export const BACK_AWAY_MS = 30 * MIN
10
13
  export const BACK_PREFIX = 'lo-back-x:'
@@ -35,8 +38,8 @@ export function backLineHtml(s, line, { esc, icon = () => '', ago = () => '' })
35
38
  const when = line.at ? ago(line.at) : ''
36
39
  const label = line.kind === 'recap' ? 'Recap' : 'Where it stopped'
37
40
  const tip = (line.kind === 'recap' ? 'Claude Code’s recap' : 'The latest checkpoint') + (when ? ' · ' + when : '')
38
- const body = line.kind === 'recap' ? esc(line.text)
39
- : (line.goal ? '<span class="bl-goal">' + esc(line.goal) + '</span> · ' : '') + esc(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + esc(line.next) + '</span>' : '')
41
+ const body = line.kind === 'recap' ? md(line.text)
42
+ : (line.goal ? '<span class="bl-goal">' + md(line.goal) + '</span> · ' : '') + md(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + md(line.next) + '</span>' : '')
40
43
  return '<div class="back-line" role="note" data-key="' + esc(line.key) + '"><p data-tip="' + esc(tip) + '"><span class="rl-k">' + label + '</span> ' + body + '</p>' +
41
44
  '<button type="button" class="back-x" data-action="back-dismiss" data-id="' + esc(s.id) + '" data-key="' + esc(line.key) + '" aria-label="Hide this line" data-tip="Hide">' + icon('x') + '</button></div>'
42
45
  }