@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11

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
@@ -2,6 +2,7 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
5
6
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
6
7
  import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
7
8
  import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
@@ -11,7 +12,7 @@ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
11
12
  import { changedKeys } from './settings-dirty.js'
12
13
  import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
13
14
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
14
- import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
15
+ import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
15
16
  import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
16
17
  import { canStartPull, pullStep, pullRelease } from './pull.js'
17
18
  import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
@@ -39,26 +40,29 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
39
40
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
40
41
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
41
42
  import { mdInline, renderSection } from './changelog-md.js'
43
+ import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
42
44
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
43
45
  import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
44
46
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
45
47
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
46
48
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
47
49
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
48
- import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
50
+ import { cutTitle } from './session-title.js'
51
+ import { sessionTitle, sessionWhere, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
49
52
  import { statusView, elapsedText } from './live-status.js'
50
53
  import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
51
54
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
52
55
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
53
56
  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
- import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
57
+ import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
55
58
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
59
  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
60
+ import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
58
61
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
59
62
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
60
63
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
61
64
  import { lastReply, ghostShows } from './ghost.js'
65
+ import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
62
66
  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
63
67
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
64
68
  import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
@@ -73,6 +77,9 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
73
77
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
74
78
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
75
79
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
80
+ import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
81
+ import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
82
+ import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
76
83
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
77
84
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
78
85
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -468,15 +475,38 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
468
475
  // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
469
476
  // 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
470
477
  const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
478
+ // 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
479
+ // are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
480
+ const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
481
+ // 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
482
+ // sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
483
+ const hooksQuietHtml = (s) => {
484
+ if (!s || s.endedAt) return ''
485
+ const k = s.hooksSkipped
486
+ if (k && k.label) return '<span class="s-hooks s-hooks-skip' + (k.applied ? ' s-hooks-applied' : '') + '" data-tip="' + esc(k.tip || k.label) + '"><span class="s-hooks-ring" aria-hidden="true"></span><span class="s-hooks-t">' + esc(k.label) + '</span></span>' + hooksFixButtonHtml(s, { esc }) // 2026-11-03 applied: "restart Claude Code to apply", no button
487
+ return s.hooksQuiet ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : ''
488
+ }
489
+ // the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
490
+ document.addEventListener('click', (e) => {
491
+ const b = e.target.closest && e.target.closest('[data-hookfix]')
492
+ if (!b) return
493
+ e.preventDefault()
494
+ const sid = b.dataset.hookfix
495
+ const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
496
+ openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
497
+ })
471
498
  const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
472
499
 
473
500
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
474
501
 
475
502
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
476
- const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
503
+ const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
477
504
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
478
- function cpExtras(cp, paths = null) {
479
- let h = ''
505
+ // decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
506
+ const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
507
+ const decidedSecHtml = (cp, log) => '<div class="cpx-sec cpx-decided" data-cp="' + esc(cp.id || '') + '"><div class="cpx-h">Decided</div>' + decidedListHtml(cp.decided, { esc, log, readOnly: true }) + '</div>'
508
+ function cpExtras(cp, paths = null, decided = null) {
509
+ let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
480
510
  for (const [k, title] of EXTRA_TITLES) {
481
511
  const v = cp[k]
482
512
  if (!v || (Array.isArray(v) && !v.length)) continue
@@ -495,11 +525,11 @@ function cpExtras(cp, paths = null) {
495
525
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
496
526
  // part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
497
527
  // 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 } = {}) {
528
+ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
499
529
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
500
530
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
501
531
  const you = /^YOU:/i.test(cp.next || '')
502
- const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
532
+ const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
503
533
  const dd = (t, cls = '') => {
504
534
  if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
505
535
  const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
@@ -518,7 +548,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
518
548
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
519
549
  (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>' : '') +
520
550
  '</dl>' +
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>' : '')
551
+ (more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + esc(cpFoldSummary(cp, { decided: decidedHere(cp, decided) })) + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths, decided) + '</div></details>' : '')
522
552
  }
523
553
 
524
554
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -1104,7 +1134,11 @@ function card(s) {
1104
1134
  // own node is kept between the page's redraws (the live poll repaints the page
1105
1135
  // every few seconds) so the scroll, the drawn code and the open groups stay.
1106
1136
  // Mark reviewed is the session's own record (session.md, like a pin).
1107
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') }
1137
+ // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1138
+ const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1139
+ const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1140
+ function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1141
+ function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1108
1142
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1109
1143
  function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
1110
1144
  function wtRestore(sid) {
@@ -1135,14 +1169,27 @@ async function wtCheck(sid) {
1135
1169
  if (wtUi.checking.has(k)) return
1136
1170
  wtUi.checking.add(k)
1137
1171
  try {
1138
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1172
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
1139
1173
  const had = wtUi.data.get(k)
1140
1174
  if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1175
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1141
1176
  wtUi.data.set(k, r)
1142
1177
  if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1143
1178
  }
1144
1179
  } catch {} finally { wtUi.checking.delete(k) }
1145
1180
  }
1181
+ // 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
1182
+ // it again, never a model run on its own: peek), its Files view and What it did show the other side at once
1183
+ function setFileScope(v, { id = null } = {}) {
1184
+ const want = v === 'folder' ? 'folder' : 'session'
1185
+ if (fileScope.get() === want) return
1186
+ fileScope.set(want)
1187
+ wtUi.filter = 'all'; wtUi.picks = []
1188
+ for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
1189
+ for (const [k, w] of widUi) { if (w.open && w.diff) loadWid(k); else if (w.diff) Object.assign(w, { tools: null, diff: null, files: {} }) }
1190
+ if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
1191
+ else if (id) repaintSession(id)
1192
+ }
1146
1193
  function wtClose() {
1147
1194
  const sid = wtUi.sid
1148
1195
  document.documentElement.classList.remove('wt-open')
@@ -1165,7 +1212,7 @@ async function wtSizeFetch(sid) {
1165
1212
  e.pending = true; wtSize.set(sid, e)
1166
1213
  const sc = wtUi.scope.get(sid)
1167
1214
  let data = e.data
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
1215
+ try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc + filesQ('&') : filesQ('?'))) } catch { data = e.data || null } // an older Mac: no line
1169
1216
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1170
1217
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1171
1218
  slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
@@ -1190,7 +1237,7 @@ let loPop = null
1190
1237
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1191
1238
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
1239
  // 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 } = {}) {
1240
+ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1194
1241
  const k = wtKey(sid)
1195
1242
  const had = wtUi.data.get(k)
1196
1243
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
@@ -1198,7 +1245,8 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1198
1245
  wtUi.loading.add(k); wtUi.error.delete(k)
1199
1246
  wtFill(wtSession(sid))
1200
1247
  try {
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
1248
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1249
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1202
1250
  wtUi.data.set(k, r)
1203
1251
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
1252
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1323,18 +1371,21 @@ function wtRevNext() {
1323
1371
  if (!done) wtReviewed(sid, step, { force: true, advance: false })
1324
1372
  wtSlideGo(st.to)
1325
1373
  }
1326
- function wtGo(sid, step, { group = null } = {}) {
1374
+ function wtGo(sid, step, { group = null, file = null } = {}) {
1327
1375
  const node = wtUi.node; if (!node) return
1328
1376
  if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
1329
1377
  if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
1330
1378
  const k = wtKey(sid)
1331
1379
  const deck = node.querySelector('#wtDeck')
1332
- if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl) } return } // 0.9.18 the slideshow: that step's slide
1380
+ // 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
1381
+ const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
1382
+ if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
1333
1383
  wtUi.cur.set(k, step)
1334
1384
  for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
1335
1385
  for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
1336
1386
  const el = node.querySelector('#wt-' + CSS.escape(step))
1337
- if (el) { el.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1387
+ const row = fileRow()
1388
+ if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1338
1389
  const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
1339
1390
  }
1340
1391
  // 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
@@ -1396,8 +1447,11 @@ function wtAsk(sid, text) {
1396
1447
  const data = wtUi.data.get(wtKey(sid))
1397
1448
  // 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
1398
1449
  const picks = wtPicksNow(data)
1450
+ // 2026-11-03 which changes the question is about, as the toggle has it right now
1451
+ const line = scopeAskLine(data)
1452
+ const lead = (m) => (line ? line + '\n' + m : m)
1399
1453
  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)
1454
+ const msg = lead(refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text))
1401
1455
  wtUi.picks = []
1402
1456
  return wtAskPut(sid, msg)
1403
1457
  }
@@ -1405,7 +1459,7 @@ function wtAsk(sid, text) {
1405
1459
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1406
1460
  const co = st && st.callouts[0]
1407
1461
  const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
1408
- const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
1462
+ const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
1409
1463
  wtAskPut(sid, msg)
1410
1464
  }
1411
1465
  function wtAskPut(sid, msg) {
@@ -1772,6 +1826,9 @@ function repoRow(shown, f, tail = '') {
1772
1826
  return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
1773
1827
  }
1774
1828
 
1829
+ // 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
1830
+ // (the counts are per log; a chip's log is its sessions' decisionRepo)
1831
+ const repoReviewHtml = (c) => { const rv = projectsUi.repoReviews || {}; if (!Object.keys(rv).length) return ''; const s = (state.sessions || []).find((x) => x.decisionRepo && rv[x.decisionRepo] && repoKeyOf(x) === c.key); return s ? reviewCountHtml(rv[s.decisionRepo], { esc, cls: 'pchip-rc' }) : '' }
1775
1832
  // The repo chip row, shared by the board and the timeline: All, the top 8,
1776
1833
  // and More ▾ with a filter box. chips: repoChips() output.
1777
1834
  function chipBar(chips, selected, allCount, emptyNote, tail = '') {
@@ -1782,7 +1839,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1782
1839
  // with nothing in this view: a filter is never active out of sight.
1783
1840
  chips = orderSelectedFirst(chips, selected)
1784
1841
  const { shown: row, more } = splitChips(chips, selected, 8)
1785
- const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
1842
+ const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + repoReviewHtml(c) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
1786
1843
  const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
1787
1844
  let menu = ''
1788
1845
  if (more.length) {
@@ -1796,13 +1853,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1796
1853
  // ------------------------------------------------------------ projects (0.9.0)
1797
1854
  // A project is a named group of repos (projects.json in the synced folder).
1798
1855
  // Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
1799
- const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
1856
+ const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
1800
1857
  async function loadProjects({ fresh = false } = {}) {
1801
1858
  if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
1802
- try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
1859
+ try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.repoReviews = d.repoReviews || {}; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
1803
1860
  return projectsUi
1804
1861
  }
1805
- function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
1862
+ function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; if (d.repoReviews) projectsUi.repoReviews = d.repoReviews; projectsUi.at = Date.now() } }
1806
1863
  // The small chip next to the repo: tap = show only this project.
1807
1864
  function projChip(s) {
1808
1865
  const p = s && s.repoProject
@@ -1865,7 +1922,7 @@ function projectRow(list, sel, action, counts, tail = '') {
1865
1922
  const manage = sel && projectsUi.mayEdit && projectsUi.list.some((p) => p.id === sel) ? '<button type="button" class="btn icon-btn more proj-more" data-action="project-manage" data-project="' + esc(sel) + '" aria-label="Project actions" data-tip="Rename, edit repos, delete" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' : ''
1866
1923
  // the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
1867
1924
  return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
1868
- chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
1925
+ chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name) + reviewCountHtml(((projectsUi.list.find((p) => p.id === c.id) || {}).decisionsToReview) || 0, { esc, cls: 'pchip-rc' }), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
1869
1926
  }
1870
1927
  // 0.9.12: the board's own way in to a project with several repos: a "New
1871
1928
  // project" button at the end of the project row (or the repo row before there
@@ -2542,9 +2599,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
2542
2599
  const SESSION_CACHE_MAX = 8
2543
2600
  function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
2544
2601
  const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
2602
+ // 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
2603
+ // (the longest whole-word cut that fits, with …), never mid-word by the
2604
+ // ellipsis; the whole name is in its tip.
2605
+ function fitTitleWords(el, text) {
2606
+ el.textContent = text
2607
+ if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
2608
+ if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
2609
+ let lo = 4, hi = text.length, best = ''
2610
+ while (lo <= hi) {
2611
+ const mid = (lo + hi) >> 1
2612
+ el.textContent = cutTitle(text, mid)
2613
+ if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
2614
+ }
2615
+ el.textContent = best || cutTitle(text, lo)
2616
+ }
2617
+ addEventListener('resize', () => { const t = document.querySelector('#live.full .lf-title'); const full = t && t.getAttribute('data-tip'); if (full) fitTitleWords(t, full) }, { passive: true }) // a turned phone re-fits
2618
+ // 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
2619
+ const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
2620
+ function whereLineHtml(s) {
2621
+ const w = sessionWhere(s)
2622
+ return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
2623
+ }
2545
2624
  function sessionShellHtml(s) {
2546
2625
  const head = s
2547
- ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2626
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2548
2627
  : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
2549
2628
  return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
2550
2629
  '<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
@@ -2750,7 +2829,7 @@ async function loadWid(id) {
2750
2829
  paintWid(id)
2751
2830
  await Promise.all([
2752
2831
  api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
2753
- api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2832
+ api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2754
2833
  ])
2755
2834
  ui.loading = false
2756
2835
  // a file you had open shows its lines again
@@ -2762,7 +2841,7 @@ async function loadWidFile(id, p) {
2762
2841
  const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
2763
2842
  if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
2764
2843
  ui.fileLoading[p] = true; paintWid(id)
2765
- try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2844
+ try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p) + filesQ('&')); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
2766
2845
  delete ui.fileLoading[p]
2767
2846
  paintWid(id)
2768
2847
  }
@@ -2850,8 +2929,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2850
2929
  // title is being made, its spinner sits in the cluster.
2851
2930
  const fullTitle = sessionTitle(s)
2852
2931
  const title = canRename
2853
- ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2854
- : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2932
+ ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
2933
+ : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
2855
2934
  const menu = fsMenuItems(s, mine)
2856
2935
  const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2857
2936
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
@@ -2861,7 +2940,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2861
2940
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2862
2941
  '<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>' +
2863
2942
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</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>' +
2943
+ '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(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>' +
2865
2944
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2866
2945
  (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>' : '') +
2867
2946
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -2895,7 +2974,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2895
2974
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2896
2975
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2897
2976
  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>'
2898
- 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 })
2977
+ side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
2978
+ decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
2979
+ if (s.decisionRepo) noteSessionDecisions(s)
2980
+ side += '<div id="fsDecided">' + panelDecidedFold(s) + '</div>' // 2026-11-05 every decision the session made (its repo's log, else all its checkpoints'); 1k Accept / Reject / Edit right here
2981
+ 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, { log: sessionLogBy(s) }) + '</div>' }, { esc })
2899
2982
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2900
2983
  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>'
2901
2984
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
@@ -3324,10 +3407,64 @@ document.addEventListener('scroll', (e) => {
3324
3407
  const el = e.target
3325
3408
  if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3326
3409
  const side = el.closest('.fs-side')
3410
+ // 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
3411
+ // content under your finger by the head's change in height, which flipped it back (the sheet jumped)
3412
+ if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
3327
3413
  fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3328
3414
  if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3329
3415
  }, true)
3330
3416
 
3417
+ // 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
3418
+ // raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
3419
+ // it. Anything else is the content's own native scroll, untouched.
3420
+ const sheetMq = window.matchMedia('(max-width: 600px)')
3421
+ {
3422
+ let d = null
3423
+ const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
3424
+ document.addEventListener('touchstart', (e) => {
3425
+ d = null
3426
+ if (!sheetMq.matches || e.touches.length !== 1) return
3427
+ const side = e.target.closest && e.target.closest('#fsSide')
3428
+ if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
3429
+ const fromPeek = !!e.target.closest('.fs-peek')
3430
+ const inner = side.querySelector('.fs-side-in')
3431
+ if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
3432
+ // a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
3433
+ for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
3434
+ const p = e.touches[0]
3435
+ d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
3436
+ }, { passive: true })
3437
+ document.addEventListener('touchmove', (e) => {
3438
+ if (!d || e.touches.length !== 1) return
3439
+ const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
3440
+ if (!d.mode) {
3441
+ d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
3442
+ if (!d.mode) return
3443
+ if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
3444
+ d.travel = travel(d.side)
3445
+ d.side.classList.add('dragging')
3446
+ }
3447
+ if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
3448
+ const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
3449
+ d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
3450
+ }, { passive: false })
3451
+ const end = (e, cancel) => {
3452
+ const g = d
3453
+ d = null
3454
+ if (!g || g.mode !== 'drag') return
3455
+ g.side.classList.remove('dragging')
3456
+ g.side.style.transform = ''
3457
+ const p = e.changedTouches && e.changedTouches[0]
3458
+ if (cancel || !p) return
3459
+ if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
3460
+ toggleFsSheet()
3461
+ sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
3462
+ }
3463
+ }
3464
+ document.addEventListener('touchend', (e) => end(e, false), { passive: true })
3465
+ document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
3466
+ }
3467
+ const sheetUi = { ignoreClick: 0 }
3331
3468
  function toggleFsSheet() {
3332
3469
  const side = document.getElementById('fsSide')
3333
3470
  if (!side) return
@@ -3379,21 +3516,99 @@ function setFullClass(on) {
3379
3516
  applyNav()
3380
3517
  }
3381
3518
  // The window as you see it: on a phone the keyboard takes the bottom of it,
3382
- // and the composer stays just above the keyboard.
3519
+ // and the composer stays just above the keyboard. When the keyboard closes the
3520
+ // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3521
+ // its pan behind after a send, which put full screen ~300px down the screen).
3522
+ const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
3523
+ // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3524
+ function fitSheetAside() {
3525
+ const html = document.documentElement, a = document.activeElement
3526
+ const typing = isTypingTarget(a)
3527
+ const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
3528
+ const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
3529
+ html.classList.toggle('lf-typing', aside)
3530
+ }
3383
3531
  function fitFullViewport() {
3384
3532
  const vv = window.visualViewport
3385
- const st = document.documentElement.style
3386
- if (!document.documentElement.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); return }
3533
+ const html = document.documentElement, st = html.style
3534
+ fitSheetAside()
3535
+ if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3387
3536
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3388
3537
  const strip = document.getElementById('tabStrip')
3389
3538
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3390
3539
  st.setProperty('--lf-bar', bar + 'px')
3391
- if (!vv) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); return }
3392
- st.setProperty('--lf-h', Math.round(vv.height) + 'px')
3393
- st.setProperty('--lf-top', Math.round(vv.offsetTop) + 'px')
3394
- document.documentElement.classList.toggle('lf-kb', window.innerHeight - vv.height > 120) // the keyboard is up: no home-indicator gap under the composer
3395
- }
3396
- if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
3540
+ const typing = isTypingTarget(document.activeElement)
3541
+ const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
3542
+ if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
3543
+ st.setProperty('--lf-h', fit.h + 'px')
3544
+ st.setProperty('--lf-top', fit.top + 'px')
3545
+ html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3546
+ // the keyboard's gone: the page under full screen back where it was before the text box took focus
3547
+ if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
3548
+ if (typing) keepTypingInView()
3549
+ }
3550
+ // 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
3551
+ // bring it back): while you type, check where the composer really is against what you can see, from
3552
+ // this moment's numbers only, and if it isn't in view move full screen by the difference.
3553
+ // (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
3554
+ function keepTypingInView() {
3555
+ const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
3556
+ if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3557
+ const box = el.closest('.live-box.full')
3558
+ if (!box) return
3559
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3560
+ if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3561
+ }
3562
+ // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
3563
+ // page under it) fits full screen then and looks again a few beats after: a swap's last change can
3564
+ // come with no report of its own, and a passing bogus one is put right by the next look.
3565
+ function onViewportChange(ev) {
3566
+ fitFullViewport()
3567
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
3568
+ }
3569
+ function settleWhileTyping(from = 0) {
3570
+ clearTimeout(lfKb.settle)
3571
+ if (from >= SETTLE_WHILE_TYPING_MS.length) return
3572
+ let i = from
3573
+ const step = () => {
3574
+ if (!isTypingTarget(document.activeElement)) return
3575
+ fitFullViewport()
3576
+ if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
3577
+ }
3578
+ lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
3579
+ }
3580
+ if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
3581
+ window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
3582
+ for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
3583
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
3584
+ document.addEventListener('focusin', (e) => {
3585
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3586
+ if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3587
+ clearTimeout(lfKb.back); lfKb.blurAt = 0
3588
+ fitSheetAside() // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
3589
+ // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3590
+ // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3591
+ settleWhileTyping(0)
3592
+ })
3593
+ // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3594
+ // goes down, whatever iOS reports or doesn't on the way.
3595
+ document.addEventListener('focusout', (e) => {
3596
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3597
+ clearTimeout(lfKb.settle)
3598
+ if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
3599
+ lfKb.blurAt = Date.now()
3600
+ clearTimeout(lfKb.back)
3601
+ lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
3602
+ }
3603
+ let i = 0
3604
+ const step = () => {
3605
+ fitFullViewport()
3606
+ if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
3607
+ if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
3608
+ else lfKb.scroll = null
3609
+ }
3610
+ lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
3611
+ })
3397
3612
  function fullAfterRender(sid) {
3398
3613
  const box = document.querySelector('.live-box.full')
3399
3614
  setFullClass(!!box)
@@ -3401,7 +3616,9 @@ function fullAfterRender(sid) {
3401
3616
  if (box) {
3402
3617
  const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
3403
3618
  const t = box.querySelector('.lf-title')
3404
- if (t) t.textContent = sessionTitle(s) // the session's name, as text
3619
+ if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
3620
+ const w = box.querySelector('.lf-where')
3621
+ if (w) { w.textContent = sessionWhere(s); if (w.textContent) w.setAttribute('data-tip', wherePathOf(s) || w.textContent); else w.removeAttribute('data-tip') } // 2026-11-05: folder@branch, muted, its own line
3405
3622
  paintLfTabs()
3406
3623
  const ol = box.querySelector('.live-log')
3407
3624
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
@@ -3638,11 +3855,40 @@ function composerFor(el) {
3638
3855
  const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
3639
3856
  return box && !box.classList.contains('off') ? box : null
3640
3857
  }
3858
+ // The picker the paperclip opens lives outside the page (0.9.18). A phone's
3859
+ // photo picker covers the board for seconds; coming back is a wake-up (focus
3860
+ // after more than AWAY_MS away) that redraws the page, and iOS hands the
3861
+ // picked photo over only after that, to the <input> it opened from. A picker
3862
+ // inside the composer was gone by then (the redraw made a new one), so its
3863
+ // change event went nowhere and the photo never showed. This one is never
3864
+ // redrawn; the composer's own hidden input stays for drops and tests.
3865
+ let attPicker = null
3866
+ function attachPicker() {
3867
+ if (attPicker && attPicker.isConnected) return attPicker
3868
+ attPicker = document.createElement('input')
3869
+ attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
3870
+ attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
3871
+ attPicker.setAttribute('aria-hidden', 'true')
3872
+ document.body.appendChild(attPicker)
3873
+ return attPicker
3874
+ }
3875
+ function openPicker(sid) {
3876
+ if (!sid) return
3877
+ const pk = attachPicker()
3878
+ pk.dataset.sid = sid
3879
+ pk.value = ''
3880
+ pk.click() // still inside the tap: iOS opens a picker only from a user gesture
3881
+ }
3882
+ function takePicked(inp) {
3883
+ const files = Array.from(inp.files || [])
3884
+ if (!files.length) return
3885
+ inp.value = '' // the same file can be picked again
3886
+ try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
3887
+ }
3641
3888
  document.addEventListener('change', (ev) => {
3642
3889
  const inp = ev.target
3643
3890
  if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
3644
- addFiles(inp.dataset.sid, inp.files)
3645
- inp.value = '' // the same file can be picked again
3891
+ takePicked(inp)
3646
3892
  })
3647
3893
  document.addEventListener('paste', (ev) => {
3648
3894
  const t = ev.target
@@ -3836,6 +4082,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
3836
4082
  function liveAfterRender(sid) {
3837
4083
  for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
3838
4084
  bangAfterRender()
4085
+ slashAfterRender()
3839
4086
  const r = liveKeeper.after($app)
3840
4087
  hydrateAttThumbs(); barWidths($app)
3841
4088
  fullAfterRender(sid)
@@ -4259,6 +4506,91 @@ function bangAfterRender() {
4259
4506
  if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
4260
4507
  }
4261
4508
  }
4509
+ // ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
4510
+ // The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
4511
+ const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
4512
+ function slashFor(ta) {
4513
+ const box = ta && ta.closest('.live-box .composer')
4514
+ if (!box || ta.disabled) return null
4515
+ if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
4516
+ return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
4517
+ }
4518
+ function paintSlash(ta, { keep = false } = {}) {
4519
+ const box = ta && ta.closest('.composer')
4520
+ if (!box) return null
4521
+ const menu = slashFor(ta)
4522
+ let el = box.querySelector('.slash-menu')
4523
+ if (!menu) {
4524
+ if (el) el.remove()
4525
+ box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
4526
+ slashUi.active = -1
4527
+ return null
4528
+ }
4529
+ if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
4530
+ const id = 'slashMenu-' + box.dataset.sid
4531
+ const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
4532
+ if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
4533
+ box.classList.add('has-slash')
4534
+ ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
4535
+ if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
4536
+ const on = box.querySelector('.slash-opt[aria-selected="true"]')
4537
+ if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
4538
+ return menu
4539
+ }
4540
+ function pickSlash(ta, value) {
4541
+ ta.value = value
4542
+ try { ta.setSelectionRange(value.length, value.length) } catch {}
4543
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
4544
+ ta.focus({ preventScroll: true })
4545
+ }
4546
+ document.addEventListener('input', (ev) => {
4547
+ const ta = ev.target
4548
+ if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
4549
+ if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
4550
+ paintSlash(ta)
4551
+ })
4552
+ // before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
4553
+ window.addEventListener('keydown', (ev) => {
4554
+ const ta = ev.target
4555
+ if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
4556
+ const box = ta.closest('.live-box .composer')
4557
+ if (!box || !box.querySelector('.slash-menu')) return
4558
+ const k = slashKey(ev)
4559
+ if (!k) return
4560
+ const menu = slashFor(ta)
4561
+ if (!menu) return
4562
+ ev.preventDefault(); ev.stopPropagation()
4563
+ if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
4564
+ if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
4565
+ const it = menu.items[slashUi.active]
4566
+ if (it && !it.off) pickSlash(ta, it.value)
4567
+ }, true)
4568
+ // a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
4569
+ // for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
4570
+ function pickSlashOpt(o) {
4571
+ if (!o || o.getAttribute('aria-disabled') === 'true') return false
4572
+ const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
4573
+ if (ta) pickSlash(ta, o.dataset.value)
4574
+ return !!ta
4575
+ }
4576
+ let slashTap = null
4577
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
4578
+ document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
4579
+ document.addEventListener('touchstart', (ev) => {
4580
+ const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
4581
+ slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
4582
+ }, { passive: true })
4583
+ document.addEventListener('touchend', (ev) => {
4584
+ const t = slashTap
4585
+ slashTap = null
4586
+ const p = ev.changedTouches && ev.changedTouches[0]
4587
+ if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
4588
+ if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
4589
+ }, { passive: false })
4590
+ function slashAfterRender() {
4591
+ const ta = document.activeElement
4592
+ if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
4593
+ }
4262
4594
  async function liveSendClick(btn) {
4263
4595
  const box = btn.closest('.live-box')
4264
4596
  const ta = box && box.querySelector('.live-text')
@@ -4385,10 +4717,11 @@ async function aroundWire(id) {
4385
4717
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4386
4718
  }
4387
4719
  }
4388
- function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4720
+ // decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
4721
+ function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
4389
4722
  return list.map((cp) =>
4390
4723
  '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4391
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4724
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
4392
4725
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4393
4726
  filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4394
4727
  (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
@@ -4398,6 +4731,268 @@ function cpHeadLine(cp) {
4398
4731
  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>' +
4399
4732
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4400
4733
  }
4734
+ // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4735
+ const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
4736
+ const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
4737
+ // 2026-11-05 the session's rows in its repo's log (null until loaded, or with no repo), and the same keyed by text
4738
+ const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
4739
+ const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
4740
+ // The Decided fold: every decision the session made, shown whenever there's at least one
4741
+ function panelDecidedFold(s) {
4742
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
4743
+ if (!list.length) return ''
4744
+ return foldHtml({ key: 'cp-decided', title: 'Decided', count: sessionDecisionsCount(list), open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc })
4745
+ }
4746
+ // The session panel reads its repo's log (once per repo per visit, quietly); the old rows stay on screen until the new ones land
4747
+ function noteSessionDecisions(s) {
4748
+ const repo = s && s.decisionRepo
4749
+ if (!repo || decisionsUi.loadedRepo[repo]) return
4750
+ decisionsUi.loadedRepo[repo] = true
4751
+ api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4752
+ decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
4753
+ const ps = panelSess()
4754
+ if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
4755
+ }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4756
+ }
4757
+ // ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
4758
+ const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
4759
+ const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4760
+ async function viewTestplan(arg, params) {
4761
+ clearTimeout(tpUi.timer)
4762
+ if (arg === 'new') {
4763
+ const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
4764
+ const key = JSON.stringify(q)
4765
+ if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
4766
+ paintTestplanNew()
4767
+ const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
4768
+ try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
4769
+ try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
4770
+ paintTestplanNew()
4771
+ const w = tpUi.nu.preview && tpUi.nu.preview.walking
4772
+ if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
4773
+ return
4774
+ }
4775
+ const machine = params.get('machine') || null
4776
+ if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
4777
+ tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
4778
+ paintTestplan()
4779
+ if (tpUi.plan && tpUi.plan.status === 'running') tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === arg) viewTestplan(arg, params) }, 1500)
4780
+ }
4781
+ function paintTestplanNew() {
4782
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
4783
+ const n = tpUi.nu
4784
+ $app.innerHTML = testplanNewHtml(n.preview, { minutes: n.minutes, trusted: tpTrusted(), starting: n.starting, recent: n.recent, here: state.health && state.health.machine }, { esc }) + (n.error ? '<div class="banner warn" role="alert"><span>' + esc(n.error) + '</span></div>' : '')
4785
+ }
4786
+ function paintTestplan() {
4787
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
4788
+ $app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
4789
+ }
4790
+ async function tpCheck(id, check, body) {
4791
+ try { tpUi.plan = await api('POST', '/v1/testplans/' + encodeURIComponent(id) + '/check' + (tpUi.machine ? '?machine=' + encodeURIComponent(tpUi.machine) : ''), { check, ...body }); paintTestplan() } catch (e) { toast(e.message || String(e)) }
4792
+ }
4793
+ document.addEventListener('click', async (ev) => {
4794
+ const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
4795
+ if (!b || b.tagName === 'FORM' || b.disabled) return
4796
+ const a = b.dataset.action
4797
+ if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
4798
+ if (a === 'tp-run' || a === 'tp-walk') {
4799
+ const n = tpUi.nu
4800
+ if (!n) return
4801
+ const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
4802
+ if (a === 'tp-walk') { try { await api('POST', '/v1/testplans/walk', body); toast('Walking through them one at a time'); viewTestplan('new', state.route.params) } catch (e) { toast(e.message || String(e)) } return }
4803
+ n.starting = true; paintTestplanNew()
4804
+ try { const r = await api('POST', '/v1/testplans', { ...body, minutes: n.minutes }, { timeoutMs: 90000 }); location.hash = testplanHref(r.id) } catch (e) { n.starting = false; n.error = e.message || String(e); paintTestplanNew() }
4805
+ return
4806
+ }
4807
+ const id = b.dataset.id, check = b.dataset.check
4808
+ if (a === 'tp-tick') { const cur = tpUi.plan && tpUi.plan.ticks && tpUi.plan.ticks[check]; const want = b.dataset.state || null; return tpCheck(id, check, { state: cur && cur.state === want ? null : want }) }
4809
+ if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
4810
+ if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
4811
+ if (a === 'tp-copy') { const md = tpUi.plan && planMarkdown(tpUi.plan); if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4812
+ if (a === 'tp-download') {
4813
+ if (!tpUi.plan) return
4814
+ const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
4815
+ const x = document.createElement('a'); x.href = url; x.download = exportName('Test plan ' + ((tpUi.plan.scope && tpUi.plan.scope.label) || '')); document.body.appendChild(x); x.click(); x.remove()
4816
+ setTimeout(() => URL.revokeObjectURL(url), 4000)
4817
+ return
4818
+ }
4819
+ if (a === 'tp-delete') { if (!confirm('Delete this test plan?')) return; try { await api('DELETE', '/v1/testplans/' + encodeURIComponent(id)); location.hash = '#/prs' } catch (e) { toast(e.message || String(e)) } }
4820
+ })
4821
+ document.addEventListener('submit', async (ev) => {
4822
+ const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
4823
+ if (!f) return
4824
+ ev.preventDefault()
4825
+ if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
4826
+ if (f.dataset.action === 'tp-propose-save') {
4827
+ try {
4828
+ const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
4829
+ tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
4830
+ } catch (e) { toast(e.message || String(e)) }
4831
+ }
4832
+ })
4833
+ // 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
4834
+ // count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
4835
+ function paintPanelDecided(s) {
4836
+ const slot = document.getElementById('fsDecided')
4837
+ if (!slot || !s) return
4838
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
4839
+ const fold = slot.querySelector('[data-fold="cp-decided"]')
4840
+ if (!list.length) { if (fold) slot.innerHTML = ''; return }
4841
+ if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
4842
+ const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
4843
+ if (n && n.textContent !== cnt) n.textContent = cnt
4844
+ const box = fold.querySelector('.fs-fold-b')
4845
+ const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
4846
+ if (box && box.innerHTML !== html) box.innerHTML = html
4847
+ // the Timeline's older checkpoints read the same log
4848
+ const by = sessionLogBy(s)
4849
+ if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
4850
+ const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
4851
+ if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
4852
+ }
4853
+ }
4854
+ // 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
4855
+ function refreshPanelDecisions(s) {
4856
+ if (!s || !s.decisionRepo) return
4857
+ decisionsUi.loadedRepo[s.decisionRepo] = false
4858
+ noteSessionDecisions(s)
4859
+ }
4860
+ // ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
4861
+ async function viewDecisions(arg) {
4862
+ const raw = decodeURIComponent(arg || '')
4863
+ if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
4864
+ decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
4865
+ const want = state.route && state.route.params && state.route.params.get('d')
4866
+ if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
4867
+ paintDecisions()
4868
+ if (want) { const el = document.getElementById('dec-' + want); if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600) } }
4869
+ }
4870
+ function paintDecisions() {
4871
+ if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
4872
+ $app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
4873
+ if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
4874
+ }
4875
+ async function decisionAction(repo, ref, action, body = {}) {
4876
+ try {
4877
+ const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
4878
+ decisionsUi.loadedRepo = {}
4879
+ // a walkthrough citing it shows its new word (Proposed goes once accepted)
4880
+ const dd = r && r.decision
4881
+ if (dd) for (const w of wtUi.data.values()) for (const x of (w && w.decisions) || []) if (x.id === dd.id) { if (dd.status === 'proposed') x.status = 'proposed'; else delete x.status; wtUi.sig = '' }
4882
+ if (wtUi.sid) wtFill(wtSession(wtUi.sid))
4883
+ const dv = document.querySelector('.dv-isdec:not([hidden])')
4884
+ if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
4885
+ if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
4886
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4887
+ else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
4888
+ } catch (e) { toast(e.message || String(e)) }
4889
+ }
4890
+ document.addEventListener('click', async (ev) => {
4891
+ const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
4892
+ if (!btn || btn.tagName === 'FORM') return
4893
+ const a = btn.dataset.action
4894
+ if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
4895
+ if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
4896
+ if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
4897
+ if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4898
+ if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
4899
+ if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4900
+ if (a === 'dec-show') {
4901
+ const el = document.getElementById('dec-' + btn.dataset.id)
4902
+ if (el) { el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600); return }
4903
+ if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
4904
+ return
4905
+ }
4906
+ if (a === 'dec-store') {
4907
+ const on = btn.dataset.on === 'true'
4908
+ try {
4909
+ await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
4910
+ toast(on ? 'Accepted decisions now go into each repo’s docs/DECISIONS.md' : 'The decision log stays in Leftoff; repo files are left as they are')
4911
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4912
+ else loadProjectDecisions(qaUi.token, true)
4913
+ } catch (e) { toast(e.message || String(e)) }
4914
+ }
4915
+ })
4916
+ // A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
4917
+ document.addEventListener('click', (ev) => {
4918
+ const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
4919
+ if (!b) return
4920
+ ev.preventDefault()
4921
+ const s = wtSession(wtUi.sid)
4922
+ openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
4923
+ })
4924
+ // 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
4925
+ const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4926
+ let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
4927
+ async function openDecisionViewer(repo, id, from, ctx = {}) {
4928
+ if (!repo || !id) return
4929
+ decViewerCtx = { repo, id, from, ctx }
4930
+ let v
4931
+ try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
4932
+ const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
4933
+ const d = byId.get(id)
4934
+ const el = docViewerEl()
4935
+ el.querySelector('.dv-title').textContent = 'Decision ' + id
4936
+ el.querySelector('.dv-dl').hidden = true
4937
+ el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
4938
+ el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
4939
+ // the chain: what it replaced (oldest first), the decision, what replaced it
4940
+ const chain = []
4941
+ if (d) {
4942
+ const back = []; let cur = d, guard = 0
4943
+ while (cur && cur.supersedes && cur.supersedes.length && guard++ < 20) { const o = byId.get(cur.supersedes[0]); if (!o || back.includes(o)) break; back.unshift(o); cur = o }
4944
+ chain.push(...back, d)
4945
+ cur = d; guard = 0
4946
+ while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
4947
+ }
4948
+ // 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
4949
+ // 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
4950
+ const trusted = decisionsTrusted()
4951
+ const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
4952
+ let offer = ''
4953
+ if (d && ctx.departs && ctx.sessionId) {
4954
+ const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
4955
+ if (own.length) offer = '<section class="dec-offer"><h3>This step departs from ' + esc(d.id) + '</h3><p class="faint">If one of this session’s decisions changes it, link them so ' + esc(d.id) + ' reads as superseded.</p><ul class="dec-tl">' +
4956
+ own.map((x) => decisionItemHtml(x, { esc, byId, may: x.status === 'proposed', review: x.status === 'proposed', edit: false, trusted, replaces: x.status === 'accepted' ? d.id : null })).join('') + '</ul></section>'
4957
+ }
4958
+ el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map(item).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') + offer +
4959
+ '<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
4960
+ el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
4961
+ refUi.from = from || null
4962
+ el.hidden = false
4963
+ document.documentElement.classList.add('dv-open')
4964
+ const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
4965
+ }
4966
+ document.addEventListener('submit', (ev) => {
4967
+ const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
4968
+ if (!f) return
4969
+ ev.preventDefault()
4970
+ const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
4971
+ decisionsUi.editing = null
4972
+ decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4973
+ })
4974
+ // The project page's Decisions section
4975
+ // (2026-11-05) or a single repo's page: that repo's log (the page names its id)
4976
+ const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
4977
+ async function loadProjectDecisions(token, force = false) {
4978
+ if (!token) return
4979
+ const scopeArg = pageDecisionScope(token)
4980
+ if (!scopeArg) return
4981
+ try {
4982
+ const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
4983
+ decisionsUi.project = { token, v, scopeArg }
4984
+ paintProjectDecisions()
4985
+ } catch { decisionsUi.project = null }
4986
+ }
4987
+ function paintProjectDecisions() {
4988
+ const box = document.getElementById('projDecisionsBox')
4989
+ const pd = decisionsUi.project
4990
+ if (!box || !pd || pd.token !== qaUi.token) return
4991
+ const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
4992
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
4993
+ const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
4994
+ if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
4995
+ }
4401
4996
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4402
4997
  // your note and the files behind More (open or closed as you last left it on this device).
4403
4998
  function cpLatestHtml(cp, wtSid, paths) {
@@ -4425,10 +5020,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
4425
5020
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
4426
5021
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
4427
5022
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
4428
- const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
5023
+ const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {}, picks: new Set() } // testplan Part 3: picks, merged PRs for a test plan
4429
5024
  // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
4430
5025
  // tap repaints from what we have and reads again only when that is old.
4431
- function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
5026
+ function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list, picks: prsUi.picks } }
4432
5027
  function paintPrs(d) {
4433
5028
  const q = document.getElementById('prs-q')
4434
5029
  const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
@@ -4577,7 +5172,8 @@ function briefAction(id, sessions, viewer) {
4577
5172
 
4578
5173
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4579
5174
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4580
- const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
5175
+ // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
5176
+ const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
4581
5177
  const out = []
4582
5178
  let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4583
5179
  const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
@@ -6601,6 +7197,8 @@ async function render() {
6601
7197
  else if (r.name === 'search') await viewSearch(r.q || '')
6602
7198
  else if (r.name === 'new') await viewNewSession()
6603
7199
  else if (r.name === 'project' && r.arg) await viewProject(r.arg)
7200
+ else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
7201
+ else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
6604
7202
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6605
7203
  else if (r.name === 'setup') await viewSetup()
6606
7204
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6635,6 +7233,7 @@ async function viewProject(token) {
6635
7233
  const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
6636
7234
  qaUi.page = page
6637
7235
  paintProject()
7236
+ if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
6638
7237
  if (fresh) window.scrollTo(0, 0)
6639
7238
  const r = state.route || {}
6640
7239
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6654,6 +7253,11 @@ function paintProject() {
6654
7253
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6655
7254
  $app.innerHTML = out.html
6656
7255
  fillAsk($app, out.keys)
7256
+ if (p.scope && (p.scope.kind === 'project' || p.decisionRepo)) { // 2026-11-03 decision memory: the project's log and where it's kept; 2026-11-05 a repo's log
7257
+ const at = document.getElementById('projAsks')
7258
+ if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
7259
+ paintProjectDecisions()
7260
+ }
6657
7261
  const ta = document.getElementById('askIn')
6658
7262
  if (ta) { ta.value = state.drafts['ask:' + ((p.scope || {}).key || '')] || ''; fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
6659
7263
  }
@@ -8485,7 +9089,7 @@ document.addEventListener('click', async (ev) => {
8485
9089
  // 0.9.16 the session panel (ui/fs-side.js)
8486
9090
  if (action === 'fs-side') return toggleFsSide()
8487
9091
  if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
8488
- if (action === 'fs-sheet') return toggleFsSheet()
9092
+ if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
8489
9093
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
8490
9094
  if (action === 'tab-pin') return togglePin(id)
8491
9095
  // 0.9.17 Walk me through it
@@ -8493,6 +9097,7 @@ document.addEventListener('click', async (ev) => {
8493
9097
  if (action === 'wt-close') return wtClose()
8494
9098
  if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
8495
9099
  if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
9100
+ if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8496
9101
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8497
9102
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
8498
9103
  if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
@@ -8523,7 +9128,7 @@ document.addEventListener('click', async (ev) => {
8523
9128
  const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
8524
9129
  return
8525
9130
  }
8526
- if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
9131
+ if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
8527
9132
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8528
9133
  // 2026-10-30 walkthrough round 3
8529
9134
  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)) }
@@ -8583,6 +9188,7 @@ document.addEventListener('click', async (ev) => {
8583
9188
  // 0.9.8 What it did
8584
9189
  if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
8585
9190
  if (action === 'wid-refresh') return loadWid(id)
9191
+ if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
8586
9192
  if (action === 'wid-tool') {
8587
9193
  const ui = widOf(id), t = btn.dataset.tool
8588
9194
  ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
@@ -8623,6 +9229,8 @@ document.addEventListener('click', async (ev) => {
8623
9229
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
8624
9230
  if (action === 'pr-expand') return togglePr(btn.dataset.url)
8625
9231
  if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
9232
+ if (action === 'prs-pick') { const u = btn.dataset.url; if (prsUi.picks.has(u)) prsUi.picks.delete(u); else prsUi.picks.add(u); return paintPrsBody() } // testplan Part 3
9233
+ if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
8626
9234
  if (action === 'prs-older') { const d = prsUi.data[prsUi.view]; if (!d) return; d.olderLoading = true; btn.disabled = true; btn.textContent = 'Loading…'; prsUi.done[prsUi.view] = (Number(d.doneShown) || 0) + 100; prsUi.at[prsUi.view] = 0; return render() } // 0.9.12
8627
9235
  if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
8628
9236
  if (action === 'pr-unlink') {
@@ -9151,7 +9759,7 @@ document.addEventListener('click', async (ev) => {
9151
9759
  if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
9152
9760
  if (action === 'lv-queued-now') return sendNowClick(btn)
9153
9761
  if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
9154
- if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
9762
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
9155
9763
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
9156
9764
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
9157
9765
  if (action === 'att-open') return openAttachment(btn)
@@ -9491,7 +10099,7 @@ function docViewerEl() {
9491
10099
  document.body.appendChild(el)
9492
10100
  return el
9493
10101
  }
9494
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
10102
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9495
10103
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9496
10104
  async function openDoc(sessionId, { path, plan } = {}) {
9497
10105
  if (!sessionId || (!path && !plan)) return