@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/CHANGELOG.md +19 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +144 -2
- package/ui/app.js +332 -18
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/icons.js +4 -0
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/walk-lines.js +119 -0
- package/ui/walk-md.js +40 -0
- package/ui/walk-pop.js +24 -5
- package/ui/walk-spots.js +89 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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' ?
|
|
39
|
-
: (line.goal ? '<span class="bl-goal">' +
|
|
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
|
}
|