@leftoff/cli 0.9.17-beta.9 → 0.9.18-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -23,7 +23,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
23
23
  import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
24
24
  import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
25
25
  import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
26
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
26
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
27
27
  import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
28
28
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
29
29
  import { wireTips } from './tips.js'
@@ -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: {} }
@@ -526,7 +529,7 @@ function agentChip(a, { title } = {}) {
526
529
 
527
530
  // 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
528
531
  function codexBadge(s) {
529
- return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" title="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
532
+ return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
530
533
  }
531
534
 
532
535
  // 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
@@ -1085,7 +1088,7 @@ function card(s) {
1085
1088
  '<div class="row1"><div class="title">' + cardGlyph(s) +
1086
1089
  (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
1087
1090
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
1088
- '</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1091
+ '</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1089
1092
  '<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
1090
1093
  (s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
1091
1094
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
@@ -1120,8 +1123,26 @@ async function wtOpen(sid, scope = null) {
1120
1123
  if (!isFullLive(sid)) { location.hash = fullHref(sid); return }
1121
1124
  if (phoneNow() && fsSheetUi.open === sid) fsSheetUi.open = null
1122
1125
  repaintSession(sid)
1126
+ const had = wtUi.data.get(wtKey(sid))
1127
+ if (had && had.status === 'ok' && !wtUi.loading.has(wtKey(sid))) wtCheck(sid) // 2026-10-30 back to one it has: shown at once, checked quietly
1123
1128
  wtLoad(sid)
1124
1129
  }
1130
+ // 2026-10-30 (asked: "is it stored in a way that we can nav away and nav back to it?") coming back to a
1131
+ // walkthrough this page already has shows it at once (no model run, no spinner) and asks the daemon,
1132
+ // quietly, whether the change moved on since (peek: it never writes one); if it did, the stale bar shows
1133
+ async function wtCheck(sid) {
1134
+ const k = wtKey(sid)
1135
+ if (wtUi.checking.has(k)) return
1136
+ wtUi.checking.add(k)
1137
+ try {
1138
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1139
+ const had = wtUi.data.get(k)
1140
+ if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1141
+ wtUi.data.set(k, r)
1142
+ if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1143
+ }
1144
+ } catch {} finally { wtUi.checking.delete(k) }
1145
+ }
1125
1146
  function wtClose() {
1126
1147
  const sid = wtUi.sid
1127
1148
  document.documentElement.classList.remove('wt-open')
@@ -1147,30 +1168,47 @@ async function wtSizeFetch(sid) {
1147
1168
  try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
1148
1169
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1149
1170
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1150
- slot.innerHTML = sizeLineHtml(data, { esc, icon })
1171
+ slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
1151
1172
  const b = slot.closest('.fs-wt'); if (b) b.classList.toggle('none', !!(data && data.files === 0))
1152
1173
  }
1153
1174
  if (loPop) loPop.refresh('wt', sid)
1154
1175
  }
1176
+ // 2026-10-30 the sidebar's "3 of 7 reviewed": your marks on the walkthrough change-size names (pending ones too)
1177
+ function wtRevCount(s, size) {
1178
+ const fp = size && size.walk && size.walk.fp
1179
+ if (!s || !fp) return 0
1180
+ const rev = reviewedSet(s, fp)
1181
+ for (const x of wtUi.pending) if (x.fp === fp) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1182
+ return rev.size
1183
+ }
1184
+ function wtSizeRepaint(sid) {
1185
+ const data = (wtSize.get(sid) || {}).data
1186
+ for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
1187
+ }
1155
1188
  let loPop = null
1156
1189
  // 0.9.17: nothing new since the last checkpoint → the whole session, with a quiet note ("No changes" only when that's empty too)
1157
1190
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1158
- async function wtLoad(sid, { fresh = false } = {}) {
1191
+ // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
+ // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1193
+ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1159
1194
  const k = wtKey(sid)
1160
1195
  const had = wtUi.data.get(k)
1161
- if (!fresh && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1162
- if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh)) return wtFill(wtSession(sid))
1196
+ if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1197
+ if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
1163
1198
  wtUi.loading.add(k); wtUi.error.delete(k)
1164
1199
  wtFill(wtSession(sid))
1165
1200
  try {
1166
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1201
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }) }, { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1167
1202
  wtUi.data.set(k, r)
1168
1203
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
+ // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
1205
+ if (r && r.status === 'ok' && Array.isArray(r.reviewedSteps) && r.fingerprint) for (const x of [wtSession(sid), state.sessions.find((y) => y.id === sid)]) if (x) x.reviewed = { ...(x.reviewed || {}), [r.fingerprint]: r.reviewedSteps }
1169
1206
  if (r && r.status === 'no_range' && wtUi.scope.get(sid) === 'checkpoint') { wtUi.scope.set(sid, 'session'); wtUi.loading.delete(k); return wtLoad(sid) }
1170
1207
  if (r && r.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); wtUi.loading.delete(k); return wtLoad(sid) }
1171
1208
  } catch (e) { wtUi.error.set(k, (e && e.message) || 'Couldn’t read the change.') }
1172
1209
  wtUi.loading.delete(k)
1173
1210
  if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1211
+ const z = wtSize.get(sid); if (z && !z.pending) { z.at = 0; wtSizeFetch(sid) } // 2026-10-30 the sidebar's reviewed count follows the walkthrough just read
1174
1212
  }
1175
1213
  // The node for the page's #wtMount: built when what it shows changed, else moved back in.
1176
1214
  function wtFill(s) {
@@ -1181,8 +1219,8 @@ function wtFill(s) {
1181
1219
  const fp = data && data.fingerprint
1182
1220
  const rev = reviewedSet(s, fp)
1183
1221
  for (const x of wtUi.pending) if (x.fp === fp) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
1184
- const ui = { scope: wtUi.scope.get(s.id) || 'session', view: wtUi.view, filter: wtUi.filter, open: wtUi.code, groups: wtUi.groups, cur: wtUi.cur.get(k) || null, loading: wtUi.loading.has(k), error: wtUi.error.get(k) || null, ask: wtUi.ask, fellBack: wtUi.fellBack.has(s.id), deck: wtDeckOn(), slide: wtSlideGet(fp, wtUi.filter) }
1185
- const sig = k + '|' + (data ? (data.fingerprint || data.status) + (data.cached ? 'c' : '') : '-') + '|' + JSON.stringify([ui.view, ui.filter, ui.loading, ui.error, ui.fellBack, ui.deck, [...wtUi.code], (data && data.scopes || []).map((x) => x.key + x.available).join()])
1222
+ const ui = { scope: wtUi.scope.get(s.id) || 'session', view: wtUi.view, filter: wtUi.filter, open: wtUi.code, groups: wtUi.groups, cur: wtUi.cur.get(k) || null, loading: wtUi.loading.has(k), error: wtUi.error.get(k) || null, ask: wtUi.ask, fellBack: wtUi.fellBack.has(s.id), deck: wtDeckOn(), slide: wtSlideGet(fp, wtUi.filter), sf: fp ? sfState(fp) : {}, split: wtSplitOn(), rail: wtRailOn(), spot: fp && wtUi.spot.has(fp) ? wtUi.spot.get(fp) : -1 }
1223
+ const sig = k + '|' + (data ? (data.fingerprint || data.status) + (data.cached ? 'c' : '') : '-') + '|' + JSON.stringify([ui.view, ui.filter, ui.loading, ui.error, ui.fellBack, ui.deck, [...wtUi.code], (data && data.scopes || []).map((x) => x.key + x.available).join(), ui.split, data && data.stale ? data.stale.fingerprint + ':' + data.stale.count : ''])
1186
1224
  let node = wtUi.node
1187
1225
  if (!node || wtUi.sig !== sig) {
1188
1226
  // (the old node may be out of the page already, reading 0: what was recorded as you scrolled wins)
@@ -1196,9 +1234,10 @@ function wtFill(s) {
1196
1234
  node.innerHTML = walkthroughHtml({ s, data, ui, reviewed: rev, esc, icon, when })
1197
1235
  wtUi.node = node; wtUi.sig = sig
1198
1236
  mount.replaceChildren(node)
1199
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1237
+ wtHydrate(node, data)
1200
1238
  wtStuck(document.querySelector('.wt-scroll')) // 0.9.18
1201
1239
  wtDeckPaint(node, { first: true }) // 0.9.18 the slideshow: its progress, its dots in view
1240
+ wtSideWidth(node) // 2026-10-30 the file list's width, as you left it
1202
1241
  const sc = node.querySelector('#wtScroll'); if (sc && keep) sc.scrollTop = keep
1203
1242
  const sd = node.querySelector('.wt-side'); if (sd && keepSide) sd.scrollTop = keepSide
1204
1243
  } else {
@@ -1211,6 +1250,7 @@ function wtFill(s) {
1211
1250
  }
1212
1251
  wtMarks(s)
1213
1252
  }
1253
+ wtPicksPaint() // 2026-10-30 picked lines, their chips, the floating Ask
1214
1254
  if (!data && !ui.loading && !ui.error) setTimeout(() => wtLoad(s.id), 0) // opened by a reload: read it now
1215
1255
  }
1216
1256
  // 0.9.18 where each scroll box in the walkthrough was (the slide or long page, the file list, the
@@ -1252,11 +1292,11 @@ function wtMarks(s) {
1252
1292
  for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
1253
1293
  const p = node.querySelector('#wtProg'); if (p) p.textContent = (data.steps || []).filter((x) => rev.has(x.id)).length + ' of ' + (data.steps || []).length + ' reviewed'
1254
1294
  }
1255
- async function wtReviewed(sid, step) {
1295
+ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
1256
1296
  const s = wtSession(sid)
1257
1297
  const data = wtUi.data.get(wtKey(sid))
1258
1298
  if (!s || !data || !data.fingerprint) return
1259
- const on = !reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && !x.on)
1299
+ const on = force != null ? force : !reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && !x.on)
1260
1300
  const x = { fp: data.fingerprint, step, on }
1261
1301
  wtUi.pending = wtUi.pending.filter((p) => !(p.fp === x.fp && p.step === step)).concat([x])
1262
1302
  wtMarks(s)
@@ -1268,7 +1308,20 @@ async function wtReviewed(sid, step) {
1268
1308
  } catch (e) { toast(e.message, 4200) }
1269
1309
  wtUi.pending = wtUi.pending.filter((p) => p !== x)
1270
1310
  wtMarks(s)
1271
- if (on) { const nx = stepAfter(data.steps || [], step, 1, wtUi.filter); if (nx && nx !== step) wtGo(sid, nx) }
1311
+ wtSizeRepaint(sid) // 2026-10-30 the sidebar's reviewed count
1312
+ if (on && advance) { const nx = stepAfter(data.steps || [], step, 1, wtUi.filter); if (nx && nx !== step) wtGo(sid, nx) }
1313
+ }
1314
+ // 0.9.19 (Joel): "Mark reviewed & next" in the slides' bar: marks the step on screen (never
1315
+ // un-marks it) and moves on; on the last step it finishes on the overview.
1316
+ function wtRevNext() {
1317
+ const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
1318
+ const slides = [...deck.children], cur = wtDeckCur()
1319
+ const st = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), cur)
1320
+ if (!st.show) return void wtSlideGo(cur + 1)
1321
+ const step = slides[cur].dataset.step, sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
1322
+ const done = s && data && (reviewedSet(s, data.fingerprint).has(step) || wtUi.pending.some((x) => x.fp === data.fingerprint && x.step === step && x.on)) && !wtUi.pending.some((x) => data && x.fp === data.fingerprint && x.step === step && !x.on)
1323
+ if (!done) wtReviewed(sid, step, { force: true, advance: false })
1324
+ wtSlideGo(st.to)
1272
1325
  }
1273
1326
  function wtGo(sid, step, { group = null } = {}) {
1274
1327
  const node = wtUi.node; if (!node) return
@@ -1315,6 +1368,8 @@ function wtSlideGo(i) {
1315
1368
  if (nav) {
1316
1369
  nav.querySelector('.wt-dprev').disabled = i === 0
1317
1370
  nav.querySelector('.wt-dnext').disabled = i === n - 1
1371
+ const rv = nav.querySelector('#wtDrev'), rs = revNextState(slides.map((x) => ({ step: x.dataset.step || null })), i)
1372
+ if (rv) { rv.hidden = !rs.show; const [l, sh] = revNextLabel(rs.last); rv.setAttribute('aria-label', l); rv.dataset.tip = rs.last ? REVFINISH_TIP : REVNEXT_TIP; const a1 = rv.querySelector('.wt-dl-l'), a2 = rv.querySelector('.wt-dl-s'); if (a1) a1.textContent = l; if (a2) a2.textContent = sh }
1318
1373
  const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
1319
1374
  const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
1320
1375
  ;[...nav.querySelectorAll('.wt-dot')].forEach((d, j) => { d.classList.toggle('cur', j === i); if (j === i) d.setAttribute('aria-current', 'step'); else d.removeAttribute('aria-current') })
@@ -1322,7 +1377,7 @@ function wtSlideGo(i) {
1322
1377
  if (sc) sc.scrollTop = wtSlideScroll.has(fp + '|' + i) ? wtSlideScroll.get(fp + '|' + i) : Math.min(sc.scrollTop, deck.offsetTop)
1323
1378
  wtSlideSet(fp, wtUi.filter, i)
1324
1379
  wtDeckPaint(node)
1325
- hydrateWalkthrough(node, data, { renderDiff, esc, icon })
1380
+ wtHydrate(node, data)
1326
1381
  wtStuck(sc)
1327
1382
  return true
1328
1383
  }
@@ -1338,12 +1393,30 @@ 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) {
1412
+ // 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
1413
+ const cs = wtSession(sid)
1414
+ if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
1415
+ const tid = cs.terminal.termId
1416
+ wtUi.ask = ''; wtClose()
1417
+ terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
1418
+ return
1419
+ }
1347
1420
  const k = 'live:' + sid
1348
1421
  const typed = ((state.drafts && state.drafts[k]) || '').trim()
1349
1422
  state.drafts[k] = typed ? typed + '\n\n' + msg : msg
@@ -1361,6 +1434,233 @@ function wtExport(kind) {
1361
1434
  const a = document.createElement('a'); a.href = url; a.download = exportName(title); document.body.appendChild(a); a.click(); a.remove()
1362
1435
  setTimeout(() => URL.revokeObjectURL(url), 4000)
1363
1436
  }
1437
+ // ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
1438
+ // Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
1439
+ // number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
1440
+ // file, any step). On a phone: tap to pick, tap another line of the same snippet to stretch it.
1441
+ // The picks show as chips over the ask box (× takes one off, the chip goes to its lines) and a
1442
+ // floating "Ask about these lines" sits by the newest one.
1443
+ function wtPicksNow(data) {
1444
+ const fp = data && data.fingerprint
1445
+ if (!fp || wtUi.picksFp !== fp) { wtUi.picks = []; wtUi.picksFp = fp || '' }
1446
+ return wtUi.picks
1447
+ }
1448
+ function wtGutHit(target) {
1449
+ const g = target && target.closest && target.closest('.wt-gd .gd-gut')
1450
+ if (!g || g.classList.contains('gd-span') || !wtUi.node || !wtUi.node.contains(g)) return null
1451
+ const row = g.closest('[data-ri]'), gd = g.closest('.wt-gd')
1452
+ if (!row || !gd || !g.textContent) return null // an empty number: the other side of a split row
1453
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1454
+ const x = patchFor(data, gd.dataset.wtk)
1455
+ if (!x) return null
1456
+ return { k: gd.dataset.wtk, file: x.file, step: x.step, ri: Number(row.dataset.ri), gd }
1457
+ }
1458
+ let wtDrag = null, wtPtr = 'mouse'
1459
+ document.addEventListener('pointerdown', (ev) => {
1460
+ wtPtr = ev.pointerType || 'mouse'
1461
+ if (wtPtr !== 'mouse' || ev.button !== 0) return
1462
+ const at = wtGutHit(ev.target)
1463
+ if (!at) return
1464
+ ev.preventDefault() // no text selection while picking
1465
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1466
+ const before = wtPicksNow(data)
1467
+ wtUi.picks = pickLine(before, at, { shift: ev.shiftKey, add: ev.metaKey || ev.ctrlKey })
1468
+ const i = wtUi.picks.findIndex((p) => p.k === at.k && at.ri >= p.a && at.ri <= p.b)
1469
+ wtDrag = !ev.shiftKey && i >= 0 && wtUi.picks.length >= before.length ? { i, k: at.k } : null
1470
+ wtPicksPaint({ focusAsk: false })
1471
+ })
1472
+ document.addEventListener('pointermove', (ev) => {
1473
+ if (!wtDrag || !(ev.buttons & 1)) return
1474
+ const el = document.elementFromPoint(ev.clientX, ev.clientY)
1475
+ const row = el && el.closest && el.closest('.wt-gd[data-wtk] [data-ri]')
1476
+ if (!row || row.closest('.wt-gd').dataset.wtk !== wtDrag.k) return
1477
+ const next = dragTo(wtUi.picks, wtDrag.i, Number(row.dataset.ri))
1478
+ const p = next[wtDrag.i], q = wtUi.picks[wtDrag.i]
1479
+ if (p && q && (p.a !== q.a || p.b !== q.b)) { wtUi.picks = next; wtPicksPaint() }
1480
+ })
1481
+ document.addEventListener('pointerup', () => { wtDrag = null })
1482
+ document.addEventListener('click', (ev) => {
1483
+ if (wtPtr === 'mouse') return
1484
+ const at = wtGutHit(ev.target)
1485
+ if (!at) return
1486
+ ev.preventDefault()
1487
+ wtUi.picks = pickLine(wtPicksNow(wtUi.data.get(wtKey(wtUi.sid))), at, { touch: true })
1488
+ wtPicksPaint()
1489
+ }, true)
1490
+ // the picked rows marked (both drawings), the chips, the floating button
1491
+ function wtPicksPaint() {
1492
+ const node = wtUi.node
1493
+ if (!node) return
1494
+ const data = wtUi.data.get(wtKey(wtUi.sid))
1495
+ const picks = wtPicksNow(data)
1496
+ for (const r of node.querySelectorAll('.wt-sel')) r.classList.remove('wt-sel', 'wt-sel-a', 'wt-sel-b')
1497
+ for (const gd of node.querySelectorAll('.wt-gd[data-wtk]')) {
1498
+ const mine = picks.filter((p) => p.k === gd.dataset.wtk)
1499
+ if (!mine.length) continue
1500
+ for (const r of gd.querySelectorAll('[data-ri]')) {
1501
+ const ri = Number(r.dataset.ri)
1502
+ const p = mine.find((x) => ri >= x.a && ri <= x.b)
1503
+ if (!p || r.classList.contains('gd-hunk') || r.classList.contains('gd-note')) continue
1504
+ r.classList.add('wt-sel')
1505
+ if (ri === p.a) r.classList.add('wt-sel-a')
1506
+ if (ri === p.b) r.classList.add('wt-sel-b')
1507
+ }
1508
+ }
1509
+ const tray = node.querySelector('#wtRefs')
1510
+ if (tray) {
1511
+ const refs = describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label } })
1512
+ 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('') +
1513
+ (refs.length > 1 ? '<button type="button" class="linkish wt-ref-clr" data-action="wt-ref-x" data-i="all">Clear</button>' : '')
1514
+ tray.hidden = !refs.length
1515
+ const inp = node.querySelector('#wtAsk')
1516
+ if (inp) inp.placeholder = refs.length ? (refs.length === 1 ? 'Ask about these lines…' : 'Ask about these ' + refs.length + ' places…') : 'Ask about this change…'
1517
+ }
1518
+ wtAskLPlace()
1519
+ }
1520
+ // the floating "Ask about these lines": under the newest pick's last row, when that row is on screen
1521
+ function wtAskLPlace() {
1522
+ const node = wtUi.node, b = node && node.querySelector('#wtAskL')
1523
+ if (!b) return
1524
+ // the newest pick that's on this slide or page
1525
+ let gd = null, row = null
1526
+ for (let i = wtUi.picks.length - 1; i >= 0 && !row; i--) {
1527
+ const p = wtUi.picks[i]
1528
+ gd = node.querySelector('.wt-gd[data-wtk="' + CSS.escape(p.k) + '"]')
1529
+ row = gd && [...gd.querySelectorAll('[data-ri="' + p.b + '"]')].find((x) => x.getClientRects().length)
1530
+ }
1531
+ const sc = node.querySelector('#wtScroll')
1532
+ const r = row && row.getBoundingClientRect(), box = sc && sc.getBoundingClientRect(), g = gd && gd.getBoundingClientRect()
1533
+ if (!r || !box || r.bottom < box.top || r.top > box.bottom - 40) { b.hidden = true; return }
1534
+ b.hidden = false
1535
+ const w = b.offsetWidth || 200
1536
+ b.style.top = Math.round(Math.min(box.bottom - 48, r.bottom + 4)) + 'px'
1537
+ b.style.left = Math.round(Math.max(box.left + 8, Math.min(g.right - w - 8, window.innerWidth - w - 8))) + 'px'
1538
+ }
1539
+ 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 })
1540
+ window.addEventListener('resize', () => { if (wtUi.node && wtUi.picks.length) requestAnimationFrame(wtAskLPlace) })
1541
+ // a drawn patch's key and a row -> on screen: the step's slide or section, its code and its file open, scrolled to
1542
+ function wtReveal(key, ri, { flash = false } = {}) {
1543
+ const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1544
+ if (!data || !wtUi.node) return null
1545
+ const k = String(key)
1546
+ if (k.startsWith('s:')) {
1547
+ const step = k.split(':')[1]
1548
+ const st = (data.steps || []).find((x) => x.id === step)
1549
+ if (wtUi.view !== 'story') { wtUi.view = 'story'; wtRemember(); wtFill(wtSession(sid)) }
1550
+ if (st && st.tag !== 'Core' && !wtUi.code.has(step) && wtDeckCur() < 0) { wtUi.code.add(step); wtFill(wtSession(sid)) }
1551
+ wtGo(sid, step)
1552
+ const slot = wtUi.node.querySelector('[data-snip="' + CSS.escape(k.slice(2)) + '"]')
1553
+ const d = slot && slot.closest('details.wt-sf')
1554
+ if (d && !d.open) { d.open = true; sfSet(data.fingerprint, [d.dataset.sfk], true); wtHydrate(wtUi.node, data) }
1555
+ } else if (k.startsWith('f:')) {
1556
+ if (wtUi.view !== 'files') { wtUi.view = 'files'; wtRemember(); wtFill(wtSession(sid)) }
1557
+ const i = (data.files || []).findIndex((f) => f.path === k.slice(2))
1558
+ const d = wtUi.node.querySelector('details.wt-file[data-file-i="' + i + '"]')
1559
+ if (d && !d.open) { d.open = true; wtHydrate(wtUi.node, data) }
1560
+ }
1561
+ wtPicksPaint()
1562
+ const gd = wtUi.node.querySelector('.wt-gd[data-wtk="' + CSS.escape(k) + '"]')
1563
+ const row = gd && gd.querySelector('[data-ri="' + ri + '"]')
1564
+ if (row) row.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth' })
1565
+ return row
1566
+ }
1567
+ // Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
1568
+ // (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
1569
+ function wtSpots(data) { return data && data.status === 'ok' ? spotsFor(data, readingOrder(data.steps, data)) : [] }
1570
+ function wtSpotGo(dir = 1) {
1571
+ const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
1572
+ if (!data || data.status !== 'ok' || !wtUi.node) return
1573
+ const spots = wtSpots(data)
1574
+ if (!spots.length) return
1575
+ const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
1576
+ const i = nextSpotIndex(spots.length, cur, dir)
1577
+ wtUi.spot.set(data.fingerprint, i)
1578
+ const sp = spots[i]
1579
+ const st = (data.steps || []).find((x) => x.id === sp.step)
1580
+ const sn = st && st.snippets[sp.snip]
1581
+ const rows = sn ? rowsOf(sn.patch) : []
1582
+ const ri = rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
1583
+ for (const x of wtUi.node.querySelectorAll('.wt-spot-on')) x.classList.remove('wt-spot-on')
1584
+ for (const x of wtUi.node.querySelectorAll('.wt-spot-tag')) x.remove()
1585
+ const row = wtReveal('s:' + sp.step + ':' + sp.snip, ri)
1586
+ // in the split drawing a removed line is the left half, an added one the right
1587
+ const gd = row && row.closest('.wt-gd')
1588
+ const cands = gd ? [...gd.querySelectorAll('[data-ri="' + ri + '"]')] : []
1589
+ const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0] || null
1590
+ if (at) {
1591
+ at.classList.add('wt-spot-on')
1592
+ const tag = document.createElement('span')
1593
+ tag.className = 'wt-spot-tag'
1594
+ tag.setAttribute('role', 'note')
1595
+ tag.innerHTML = icon('crosshair') + '<span></span>'
1596
+ tag.lastChild.textContent = sp.why || 'Worth a look'
1597
+ at.appendChild(tag)
1598
+ }
1599
+ const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
1600
+ const b = wtUi.node.querySelector('.wt-spot'); if (b) b.setAttribute('aria-label', 'Next spot to look at, ' + spotCountWords(spots.length, i))
1601
+ }
1602
+ // Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
1603
+ // only on a screen 1000px or wider, else unified (the toggle is hidden there)
1604
+ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN + 'px)') : null
1605
+ function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
1606
+ function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
1607
+ if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
1608
+ function wtHydrate(node, data) { return hydrateWalkthrough(node, data, { renderDiff, renderSplit, split: wtSplitOn(), esc, icon }) }
1609
+ // the file list folds to a rail ([ or its button) and its edge drags; both kept per device
1610
+ function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
1611
+ function wtListUp() {
1612
+ const p = wtUi.sid && wtUi.node && wtUi.node.isConnected && wtUi.node.querySelector('#wt2p')
1613
+ if (!p || !p.getClientRects().length) return null
1614
+ const shown = (el) => el && getComputedStyle(el).display !== 'none'
1615
+ return shown(p.querySelector(':scope > .wt-side')) || shown(p.querySelector(':scope > .wt-rail')) ? p : null
1616
+ }
1617
+ function wtRail() {
1618
+ const p = wtListUp()
1619
+ if (!p) return
1620
+ const on = !p.classList.contains('rail')
1621
+ p.classList.toggle('rail', on)
1622
+ try { if (on) localStorage.setItem('lo-wt-rail', '1'); else localStorage.removeItem('lo-wt-rail') } catch {}
1623
+ const b = p.querySelector(on ? '.wt-rail .wt-railt' : '.wt-side .wt-railt')
1624
+ if (b && document.activeElement && document.activeElement.closest && document.activeElement.closest('.wt-railt')) b.focus({ preventScroll: true })
1625
+ }
1626
+ const clampW = (w) => Math.max(WT_SIDE_MIN, Math.min(WT_SIDE_MAX, Math.round(w)))
1627
+ function wtSideW() { try { const w = Number(localStorage.getItem('lo-wt-side-w')); return w ? clampW(w) : WT_SIDE_DEFAULT } catch { return WT_SIDE_DEFAULT } }
1628
+ function wtSideWidth(node, w = wtSideW()) {
1629
+ const p = node && node.querySelector('#wt2p')
1630
+ if (p) p.style.setProperty('--wt-side-w', w + 'px')
1631
+ const rz = p && p.querySelector('.wt-rz')
1632
+ if (rz) { rz.setAttribute('aria-valuemin', WT_SIDE_MIN); rz.setAttribute('aria-valuemax', WT_SIDE_MAX); rz.setAttribute('aria-valuenow', w) }
1633
+ }
1634
+ let wtRz = null
1635
+ document.addEventListener('pointerdown', (ev) => {
1636
+ const rz = ev.target && ev.target.closest && ev.target.closest('#wt2p > .wt-rz')
1637
+ if (!rz || ev.button !== 0) return
1638
+ ev.preventDefault()
1639
+ const side = rz.parentNode.querySelector('.wt-side')
1640
+ wtRz = { left: side.getBoundingClientRect().left, id: ev.pointerId, rz }
1641
+ try { rz.setPointerCapture(ev.pointerId) } catch {}
1642
+ rz.classList.add('on')
1643
+ })
1644
+ document.addEventListener('pointermove', (ev) => {
1645
+ if (!wtRz) return
1646
+ wtSideWidth(wtUi.node, clampW(ev.clientX - wtRz.left))
1647
+ })
1648
+ document.addEventListener('pointerup', () => {
1649
+ if (!wtRz) return
1650
+ const p = wtUi.node && wtUi.node.querySelector('#wt2p')
1651
+ const w = p ? parseInt(p.style.getPropertyValue('--wt-side-w'), 10) : 0
1652
+ if (w) try { localStorage.setItem('lo-wt-side-w', String(w)) } catch {}
1653
+ wtRz.rz.classList.remove('on')
1654
+ wtRz = null
1655
+ })
1656
+ document.addEventListener('keydown', (ev) => {
1657
+ const rz = ev.target && ev.target.closest && ev.target.closest('#wt2p > .wt-rz')
1658
+ if (!rz || (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight')) return
1659
+ ev.preventDefault()
1660
+ const w = clampW(wtSideW() + (ev.key === 'ArrowRight' ? 16 : -16))
1661
+ try { localStorage.setItem('lo-wt-side-w', String(w)) } catch {}
1662
+ wtSideWidth(wtUi.node, w)
1663
+ })
1364
1664
  document.addEventListener('submit', (ev) => {
1365
1665
  const f = ev.target && ev.target.closest && ev.target.closest('form.wt-ask')
1366
1666
  if (!f) return
@@ -1378,7 +1678,8 @@ document.addEventListener('toggle', (ev) => {
1378
1678
  const d = ev.target
1379
1679
  if (!d || !d.matches || !wtUi.node || !wtUi.node.contains(d)) return
1380
1680
  if (d.matches('details.wt-grp')) { if (d.open) wtUi.groups.add(d.dataset.group); else wtUi.groups.delete(d.dataset.group) }
1381
- if (d.open) hydrateWalkthrough(wtUi.node, wtUi.data.get(wtKey(wtUi.sid)), { renderDiff, esc, icon })
1681
+ 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
1682
+ if (d.open) wtHydrate(wtUi.node, wtUi.data.get(wtKey(wtUi.sid)))
1382
1683
  }, true)
1383
1684
  // j / k between steps (not while typing)
1384
1685
  document.addEventListener('keydown', (ev) => {
@@ -1386,12 +1687,16 @@ document.addEventListener('keydown', (ev) => {
1386
1687
  const t = ev.target
1387
1688
  if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return
1388
1689
  if (!document.getElementById('wtMount')) return
1690
+ // 2026-10-30 n / Shift+N: the next (previous) spot worth a real look; [ folds the file list
1691
+ if (ev.key === 'n' || ev.key === 'N') { ev.preventDefault(); return void wtSpotGo(ev.key === 'N' ? -1 : 1) }
1692
+ if (ev.key === '[') { ev.preventDefault(); return void wtRail() }
1389
1693
  // 0.9.18 the slideshow: ← / → and j / k move, ↑ / ↓ scroll the slide, Home / End jump, Space pages
1390
1694
  // (a focused button keeps its own Space and arrows; nothing is taken from a text box)
1391
1695
  if (wtDeckCur() >= 0 && (!t || t === document.body || (wtUi.node && wtUi.node.contains(t)))) {
1392
1696
  const sc = wtUi.node.querySelector('#wtScroll'), onCtl = t && t.closest && t.closest('button, a, summary, [role="radio"]')
1393
1697
  const go = (i) => { ev.preventDefault(); wtSlideGo(i) }
1394
1698
  const k = ev.key, cur = wtDeckCur()
1699
+ if ((k === 'ArrowRight' && ev.shiftKey) || (k === 'Enter' && !onCtl)) { ev.preventDefault(); return void wtRevNext() } // 0.9.19 mark reviewed & next
1395
1700
  if (k === 'ArrowRight' || k === 'j') return go(cur + 1)
1396
1701
  if (k === 'ArrowLeft' || k === 'k') return go(cur - 1)
1397
1702
  if (k === 'Home' && !onCtl) return go(0)
@@ -2280,6 +2585,7 @@ function freshSession(s) {
2280
2585
  if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
2281
2586
  return out
2282
2587
  }
2588
+ const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
2283
2589
  const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
2284
2590
  // Redraw the session page from what it has (no fetch): the bar and the switch at once.
2285
2591
  function repaintSession(id) {
@@ -2555,7 +2861,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2555
2861
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2556
2862
  '<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>' +
2557
2863
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2558
- '<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>' +
2864
+ '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (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>' +
2559
2865
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2560
2866
  (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>' : '') +
2561
2867
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -2568,7 +2874,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2568
2874
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2569
2875
  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
2876
  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)
2877
+ 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
2878
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
2573
2879
  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
2880
  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,6 +2882,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2576
2882
  side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
2577
2883
  side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
2578
2884
  side += ownerOfflineNote(s)
2885
+ side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
2579
2886
  side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
2580
2887
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
2581
2888
  // Checkpoints: a timeline, latest first, the latest open
@@ -2865,7 +3172,7 @@ function liveOpenHtml(s, v, sub) {
2865
3172
  if (isFullLive(s.id)) {
2866
3173
  const h = s.headless
2867
3174
  const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
2868
- return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
3175
+ return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
2869
3176
  '<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
2870
3177
  }
2871
3178
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
@@ -4813,6 +5120,7 @@ function walkValueOf(w) {
4813
5120
  const s = w.saved || null
4814
5121
  const choice = s ? s.choice : w.choice
4815
5122
  if (choice === 'agent') return 'agent'
5123
+ if (choice === 'codex') return 'codex' // 0.9.17
4816
5124
  if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
4817
5125
  if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
4818
5126
  return 'summarizer'
@@ -4854,7 +5162,7 @@ function paintWalk() {
4854
5162
  let h = '', h0 = ''
4855
5163
  // first: this session's own agent (recommended where Claude Code is here)
4856
5164
  const ag = opt('agent')
4857
- const fbLine = d.fallback ? 'When a session can’t be forked (a Codex session, say), ' + d.fallback.label + ' writes it.' : ''
5165
+ const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
4858
5166
  h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
4859
5167
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
4860
5168
  const cl = opt('claude')
@@ -4869,6 +5177,10 @@ function paintWalk() {
4869
5177
  h += h0
4870
5178
  h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
4871
5179
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
5180
+ // 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
5181
+ const cx = opt('codex')
5182
+ h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
5183
+ side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
4872
5184
  const smo = opt('summarizer')
4873
5185
  h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
4874
5186
  const ol = d.ollama || {}
@@ -6874,9 +7186,9 @@ async function startSend(btn) {
6874
7186
  const box = btn.closest('.start-box')
6875
7187
  const ta = box && box.querySelector('.start-text')
6876
7188
  const text = ta ? ta.value.trim() : ''
6877
- if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
6878
- if (startUi.sending) return
6879
7189
  const pick = startPick(startUi.data, startUi)
7190
+ if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
7191
+ if (startUi.sending) return
6880
7192
  if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
6881
7193
  startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
6882
7194
  try {
@@ -6885,14 +7197,21 @@ async function startSend(btn) {
6885
7197
  await uploadsSettled(START_SID)
6886
7198
  const atts = attachList(START_SID)
6887
7199
  if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
6888
- const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
7200
+ const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
6889
7201
  lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
6890
7202
  if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
6891
7203
  const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
6892
7204
  if (state.drafts) delete state.drafts.start
6893
7205
  if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
6894
- if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text); return }
6895
- startUi.ask = { ...r, machine: r.machine || pick.m.machine }
7206
+ if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
7207
+ if (r.state === 'started' && r.sessionId) {
7208
+ startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
7209
+ if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
7210
+ return
7211
+ }
7212
+ // 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
7213
+ if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
7214
+ startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
6896
7215
  pollStartAsk()
6897
7216
  } catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
6898
7217
  if (state.route && state.route.name === 'new') render()
@@ -8180,6 +8499,7 @@ document.addEventListener('click', async (ev) => {
8180
8499
  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
8500
  if (action === 'wt-slide') { ev.preventDefault(); return void wtSlideGo(Number(btn.dataset.slide) || 0) } // 0.9.18
8182
8501
  if (action === 'wt-dstep') return void wtSlideGo(wtDeckCur() + (Number(btn.dataset.dir) || 1))
8502
+ if (action === 'wt-revnext') return void wtRevNext()
8183
8503
  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
8504
  if (action === 'wt-fm-dir') { // 0.9.18 a folder in the file map folds; kept per walkthrough
8185
8505
  const k = btn.parentElement && btn.parentElement.querySelector(':scope > .wt-fm-dk')
@@ -8205,6 +8525,22 @@ document.addEventListener('click', async (ev) => {
8205
8525
  }
8206
8526
  if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
8207
8527
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8528
+ // 2026-10-30 walkthrough round 3
8529
+ 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)) }
8530
+ if (action === 'wt-rail') return void wtRail()
8531
+ if (action === 'wt-spot') return void wtSpotGo(1)
8532
+ if (action === 'wt-update') { btn.disabled = true; return wtLoad(btn.dataset.id || wtUi.sid, { update: true }) }
8533
+ if (action === 'wt-sf-all') {
8534
+ const art = btn.closest('.wt-st'); const data = wtUi.data.get(wtKey(wtUi.sid)); if (!art || !data) return
8535
+ const open = btn.dataset.open === '1', ds = [...art.querySelectorAll('details.wt-sf')]
8536
+ for (const d of ds) d.open = open
8537
+ sfSet(data.fingerprint, ds.map((d) => d.dataset.sfk), open)
8538
+ if (open) wtHydrate(wtUi.node, data)
8539
+ return
8540
+ }
8541
+ 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() }
8542
+ if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
8543
+ if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
8208
8544
  if (action === 'wt-export-html') return wtExport('html')
8209
8545
  if (action === 'wt-export-md') return wtExport('md')
8210
8546
  // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
@@ -8428,7 +8764,7 @@ document.addEventListener('click', async (ev) => {
8428
8764
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
8429
8765
  if (action === 'start-pick') {
8430
8766
  const k = btn.dataset.k, v = btn.dataset.v
8431
- if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
8767
+ if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
8432
8768
  return render()
8433
8769
  }
8434
8770
  // 0.9.16 + Add another repo, and Save these as a project…
@@ -8507,6 +8843,21 @@ document.addEventListener('click', async (ev) => {
8507
8843
  return render()
8508
8844
  }
8509
8845
  if (action === 'ask-auto') return autoRestClick(btn)
8846
+ // 1.0 Continue in terminal / Continue headless (src/run-switch.js)
8847
+ if (action === 'run-continue') {
8848
+ const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
8849
+ if (!id || hlPending.has(id) || switchPending.has(id)) return
8850
+ switchPending.add(id); btn.disabled = true
8851
+ toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
8852
+ try {
8853
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
8854
+ toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
8855
+ if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
8856
+ } catch (e) { toast(e.message, 6000) }
8857
+ switchPending.delete(id)
8858
+ repaintSession(id)
8859
+ return render()
8860
+ }
8510
8861
  if (action === 'headless-stop' || action === 'headless-run') {
8511
8862
  const op = action === 'headless-stop' ? 'stop' : 'resume'
8512
8863
  if (!id || hlPending.has(id)) return
@@ -9407,6 +9758,9 @@ async function needDismissClick(btn) {
9407
9758
 
9408
9759
  // Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
9409
9760
  function goClick(btn, id, s) {
9761
+ // 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
9762
+ // 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
9763
+ if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
9410
9764
  return goWithFeedback({
9411
9765
  id,
9412
9766
  title: s && s.title,