@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/CHANGELOG.md +21 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +221 -2
- package/ui/app.js +440 -53
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/icons.js +4 -0
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/side-fold.js +124 -0
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2155
|
-
|
|
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>'
|
|
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
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
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' : '') + '"
|
|
4050
|
-
(cp
|
|
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
|
|
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()
|