@leftoff/cli 0.9.17 → 0.9.18-beta.10

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, 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'
@@ -23,7 +24,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
23
24
  import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
24
25
  import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
25
26
  import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
26
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
27
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
27
28
  import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
28
29
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
29
30
  import { wireTips } from './tips.js'
@@ -39,13 +40,15 @@ 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
@@ -54,7 +57,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
54
57
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } 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
@@ -73,6 +76,9 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
73
76
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
74
77
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
75
78
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
79
+ import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
80
+ import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
81
+ import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
76
82
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
77
83
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
78
84
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -468,15 +474,38 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
468
474
  // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
469
475
  // 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
470
476
  const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
477
+ // 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
478
+ // are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
479
+ 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.'
480
+ // 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
481
+ // sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
482
+ const hooksQuietHtml = (s) => {
483
+ if (!s || s.endedAt) return ''
484
+ const k = s.hooksSkipped
485
+ 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
486
+ 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>' : ''
487
+ }
488
+ // the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
489
+ document.addEventListener('click', (e) => {
490
+ const b = e.target.closest && e.target.closest('[data-hookfix]')
491
+ if (!b) return
492
+ e.preventDefault()
493
+ const sid = b.dataset.hookfix
494
+ const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
495
+ openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
496
+ })
471
497
  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
498
 
473
499
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
474
500
 
475
501
  // 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']]
502
+ const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
477
503
  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 = ''
504
+ // decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
505
+ const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
506
+ 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>'
507
+ function cpExtras(cp, paths = null, decided = null) {
508
+ let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
480
509
  for (const [k, title] of EXTRA_TITLES) {
481
510
  const v = cp[k]
482
511
  if (!v || (Array.isArray(v) && !v.length)) continue
@@ -495,11 +524,11 @@ function cpExtras(cp, paths = null) {
495
524
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
496
525
  // part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
497
526
  // 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 } = {}) {
527
+ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
499
528
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
500
529
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
501
530
  const you = /^YOU:/i.test(cp.next || '')
502
- const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
531
+ const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
503
532
  const dd = (t, cls = '') => {
504
533
  if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
505
534
  const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
@@ -518,7 +547,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
518
547
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
519
548
  (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
549
  '</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>' : '')
550
+ (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
551
  }
523
552
 
524
553
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -529,7 +558,7 @@ function agentChip(a, { title } = {}) {
529
558
 
530
559
  // 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
531
560
  function codexBadge(s) {
532
- return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" title="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
561
+ return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
533
562
  }
534
563
 
535
564
  // 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
@@ -1088,7 +1117,7 @@ function card(s) {
1088
1117
  '<div class="row1"><div class="title">' + cardGlyph(s) +
1089
1118
  (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
1090
1119
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
1091
- '</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1120
+ '</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
1092
1121
  '<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
1093
1122
  (s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
1094
1123
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
@@ -1104,7 +1133,11 @@ function card(s) {
1104
1133
  // own node is kept between the page's redraws (the live poll repaints the page
1105
1134
  // every few seconds) so the scroll, the drawn code and the open groups stay.
1106
1135
  // Mark reviewed is the session's own record (session.md, like a pin).
1107
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') }
1136
+ // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1137
+ const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1138
+ const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1139
+ function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1140
+ function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1108
1141
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1109
1142
  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
1143
  function wtRestore(sid) {
@@ -1135,14 +1168,27 @@ async function wtCheck(sid) {
1135
1168
  if (wtUi.checking.has(k)) return
1136
1169
  wtUi.checking.add(k)
1137
1170
  try {
1138
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', { scope: wtUi.scope.get(sid) || 'session', stale: true, peek: true }, { timeoutMs: 60000 })
1171
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
1139
1172
  const had = wtUi.data.get(k)
1140
1173
  if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
1174
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1141
1175
  wtUi.data.set(k, r)
1142
1176
  if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
1143
1177
  }
1144
1178
  } catch {} finally { wtUi.checking.delete(k) }
1145
1179
  }
1180
+ // 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
1181
+ // it again, never a model run on its own: peek), its Files view and What it did show the other side at once
1182
+ function setFileScope(v, { id = null } = {}) {
1183
+ const want = v === 'folder' ? 'folder' : 'session'
1184
+ if (fileScope.get() === want) return
1185
+ fileScope.set(want)
1186
+ wtUi.filter = 'all'; wtUi.picks = []
1187
+ for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
1188
+ 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: {} }) }
1189
+ if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
1190
+ else if (id) repaintSession(id)
1191
+ }
1146
1192
  function wtClose() {
1147
1193
  const sid = wtUi.sid
1148
1194
  document.documentElement.classList.remove('wt-open')
@@ -1165,7 +1211,7 @@ async function wtSizeFetch(sid) {
1165
1211
  e.pending = true; wtSize.set(sid, e)
1166
1212
  const sc = wtUi.scope.get(sid)
1167
1213
  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
1214
+ 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
1215
  wtSize.set(sid, { at: Date.now(), data, pending: false })
1170
1216
  for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
1171
1217
  slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
@@ -1190,7 +1236,7 @@ let loPop = null
1190
1236
  function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
1191
1237
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1192
1238
  // 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 } = {}) {
1239
+ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1194
1240
  const k = wtKey(sid)
1195
1241
  const had = wtUi.data.get(k)
1196
1242
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
@@ -1198,7 +1244,8 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
1198
1244
  wtUi.loading.add(k); wtUi.error.delete(k)
1199
1245
  wtFill(wtSession(sid))
1200
1246
  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
1247
+ 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
1248
+ if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1202
1249
  wtUi.data.set(k, r)
1203
1250
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1204
1251
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -1323,18 +1370,21 @@ function wtRevNext() {
1323
1370
  if (!done) wtReviewed(sid, step, { force: true, advance: false })
1324
1371
  wtSlideGo(st.to)
1325
1372
  }
1326
- function wtGo(sid, step, { group = null } = {}) {
1373
+ function wtGo(sid, step, { group = null, file = null } = {}) {
1327
1374
  const node = wtUi.node; if (!node) return
1328
1375
  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
1376
  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
1377
  const k = wtKey(sid)
1331
1378
  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
1379
+ // 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
1380
+ 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 }
1381
+ 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
1382
  wtUi.cur.set(k, step)
1334
1383
  for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
1335
1384
  for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
1336
1385
  const el = node.querySelector('#wt-' + CSS.escape(step))
1337
- if (el) { el.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1386
+ const row = fileRow()
1387
+ if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
1338
1388
  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
1389
  }
1340
1390
  // 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
@@ -1396,8 +1446,11 @@ function wtAsk(sid, text) {
1396
1446
  const data = wtUi.data.get(wtKey(sid))
1397
1447
  // 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
1398
1448
  const picks = wtPicksNow(data)
1449
+ // 2026-11-03 which changes the question is about, as the toggle has it right now
1450
+ const line = scopeAskLine(data)
1451
+ const lead = (m) => (line ? line + '\n' + m : m)
1399
1452
  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)
1453
+ 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
1454
  wtUi.picks = []
1402
1455
  return wtAskPut(sid, msg)
1403
1456
  }
@@ -1405,10 +1458,18 @@ function wtAsk(sid, text) {
1405
1458
  const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
1406
1459
  const co = st && st.callouts[0]
1407
1460
  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
1461
+ const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
1409
1462
  wtAskPut(sid, msg)
1410
1463
  }
1411
1464
  function wtAskPut(sid, msg) {
1465
+ // 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
1466
+ const cs = wtSession(sid)
1467
+ if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
1468
+ const tid = cs.terminal.termId
1469
+ wtUi.ask = ''; wtClose()
1470
+ terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
1471
+ return
1472
+ }
1412
1473
  const k = 'live:' + sid
1413
1474
  const typed = ((state.drafts && state.drafts[k]) || '').trim()
1414
1475
  state.drafts[k] = typed ? typed + '\n\n' + msg : msg
@@ -1764,6 +1825,9 @@ function repoRow(shown, f, tail = '') {
1764
1825
  return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
1765
1826
  }
1766
1827
 
1828
+ // 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
1829
+ // (the counts are per log; a chip's log is its sessions' decisionRepo)
1830
+ 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' }) : '' }
1767
1831
  // The repo chip row, shared by the board and the timeline: All, the top 8,
1768
1832
  // and More ▾ with a filter box. chips: repoChips() output.
1769
1833
  function chipBar(chips, selected, allCount, emptyNote, tail = '') {
@@ -1774,7 +1838,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1774
1838
  // with nothing in this view: a filter is never active out of sight.
1775
1839
  chips = orderSelectedFirst(chips, selected)
1776
1840
  const { shown: row, more } = splitChips(chips, selected, 8)
1777
- 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>' }
1841
+ 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>' }
1778
1842
  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>'
1779
1843
  let menu = ''
1780
1844
  if (more.length) {
@@ -1788,13 +1852,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1788
1852
  // ------------------------------------------------------------ projects (0.9.0)
1789
1853
  // A project is a named group of repos (projects.json in the synced folder).
1790
1854
  // Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
1791
- const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
1855
+ const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
1792
1856
  async function loadProjects({ fresh = false } = {}) {
1793
1857
  if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
1794
- 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() }
1858
+ 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() }
1795
1859
  return projectsUi
1796
1860
  }
1797
- function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
1861
+ 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() } }
1798
1862
  // The small chip next to the repo: tap = show only this project.
1799
1863
  function projChip(s) {
1800
1864
  const p = s && s.repoProject
@@ -1857,7 +1921,7 @@ function projectRow(list, sel, action, counts, tail = '') {
1857
1921
  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>' : ''
1858
1922
  // the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
1859
1923
  return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
1860
- 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>'
1924
+ 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>'
1861
1925
  }
1862
1926
  // 0.9.12: the board's own way in to a project with several repos: a "New
1863
1927
  // project" button at the end of the project row (or the repo row before there
@@ -2534,9 +2598,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
2534
2598
  const SESSION_CACHE_MAX = 8
2535
2599
  function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
2536
2600
  const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
2601
+ // 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
2602
+ // (the longest whole-word cut that fits, with …), never mid-word by the
2603
+ // ellipsis; the whole name is in its tip.
2604
+ function fitTitleWords(el, text) {
2605
+ el.textContent = text
2606
+ if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
2607
+ if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
2608
+ let lo = 4, hi = text.length, best = ''
2609
+ while (lo <= hi) {
2610
+ const mid = (lo + hi) >> 1
2611
+ el.textContent = cutTitle(text, mid)
2612
+ if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
2613
+ }
2614
+ el.textContent = best || cutTitle(text, lo)
2615
+ }
2616
+ 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
2617
+ // 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
2618
+ const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
2619
+ function whereLineHtml(s) {
2620
+ const w = sessionWhere(s)
2621
+ return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
2622
+ }
2537
2623
  function sessionShellHtml(s) {
2538
2624
  const head = s
2539
- ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2625
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
2540
2626
  : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
2541
2627
  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>' +
2542
2628
  '<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>' +
@@ -2577,6 +2663,7 @@ function freshSession(s) {
2577
2663
  if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
2578
2664
  return out
2579
2665
  }
2666
+ const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
2580
2667
  const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
2581
2668
  // Redraw the session page from what it has (no fetch): the bar and the switch at once.
2582
2669
  function repaintSession(id) {
@@ -2741,7 +2828,7 @@ async function loadWid(id) {
2741
2828
  paintWid(id)
2742
2829
  await Promise.all([
2743
2830
  api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
2744
- api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2831
+ api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2745
2832
  ])
2746
2833
  ui.loading = false
2747
2834
  // a file you had open shows its lines again
@@ -2753,7 +2840,7 @@ async function loadWidFile(id, p) {
2753
2840
  const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
2754
2841
  if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
2755
2842
  ui.fileLoading[p] = true; paintWid(id)
2756
- 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) } }
2843
+ 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) } }
2757
2844
  delete ui.fileLoading[p]
2758
2845
  paintWid(id)
2759
2846
  }
@@ -2841,8 +2928,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2841
2928
  // title is being made, its spinner sits in the cluster.
2842
2929
  const fullTitle = sessionTitle(s)
2843
2930
  const title = canRename
2844
- ? '<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>'
2845
- : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2931
+ ? '<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>'
2932
+ : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
2846
2933
  const menu = fsMenuItems(s, mine)
2847
2934
  const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2848
2935
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
@@ -2852,7 +2939,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2852
2939
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2853
2940
  '<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>' +
2854
2941
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2855
- '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
2942
+ '<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>' +
2856
2943
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2857
2944
  (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>' : '') +
2858
2945
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -2873,6 +2960,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2873
2960
  side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
2874
2961
  side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
2875
2962
  side += ownerOfflineNote(s)
2963
+ side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
2876
2964
  side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
2877
2965
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
2878
2966
  // Checkpoints: a timeline, latest first, the latest open
@@ -2885,7 +2973,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2885
2973
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2886
2974
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2887
2975
  side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
2888
- if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
2976
+ side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
2977
+ decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
2978
+ if (s.decisionRepo) noteSessionDecisions(s)
2979
+ 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
2980
+ 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 })
2889
2981
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2890
2982
  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>'
2891
2983
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
@@ -3162,7 +3254,7 @@ function liveOpenHtml(s, v, sub) {
3162
3254
  if (isFullLive(s.id)) {
3163
3255
  const h = s.headless
3164
3256
  const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
3165
- return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
3257
+ return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
3166
3258
  '<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
3167
3259
  }
3168
3260
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
@@ -3369,21 +3461,83 @@ function setFullClass(on) {
3369
3461
  applyNav()
3370
3462
  }
3371
3463
  // The window as you see it: on a phone the keyboard takes the bottom of it,
3372
- // and the composer stays just above the keyboard.
3464
+ // and the composer stays just above the keyboard. When the keyboard closes the
3465
+ // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3466
+ // its pan behind after a send, which put full screen ~300px down the screen).
3467
+ const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
3373
3468
  function fitFullViewport() {
3374
3469
  const vv = window.visualViewport
3375
- const st = document.documentElement.style
3376
- if (!document.documentElement.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); return }
3470
+ const html = document.documentElement, st = html.style
3471
+ 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 }
3377
3472
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3378
3473
  const strip = document.getElementById('tabStrip')
3379
3474
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3380
3475
  st.setProperty('--lf-bar', bar + 'px')
3381
- if (!vv) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); return }
3382
- st.setProperty('--lf-h', Math.round(vv.height) + 'px')
3383
- st.setProperty('--lf-top', Math.round(vv.offsetTop) + 'px')
3384
- document.documentElement.classList.toggle('lf-kb', window.innerHeight - vv.height > 120) // the keyboard is up: no home-indicator gap under the composer
3385
- }
3386
- if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
3476
+ const typing = isTypingTarget(document.activeElement)
3477
+ const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
3478
+ if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
3479
+ st.setProperty('--lf-h', fit.h + 'px')
3480
+ st.setProperty('--lf-top', fit.top + 'px')
3481
+ html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3482
+ // the keyboard's gone: the page under full screen back where it was before the text box took focus
3483
+ if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
3484
+ if (typing) keepTypingInView()
3485
+ }
3486
+ // 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
3487
+ // bring it back): while you type, check where the composer really is against what you can see, from
3488
+ // this moment's numbers only, and if it isn't in view move full screen by the difference.
3489
+ // (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
3490
+ function keepTypingInView() {
3491
+ const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
3492
+ if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3493
+ const box = el.closest('.live-box.full')
3494
+ if (!box) return
3495
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3496
+ if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3497
+ }
3498
+ // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
3499
+ // page under it) fits full screen then and looks again a few beats after: a swap's last change can
3500
+ // come with no report of its own, and a passing bogus one is put right by the next look.
3501
+ function onViewportChange(ev) {
3502
+ fitFullViewport()
3503
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
3504
+ }
3505
+ function settleWhileTyping(from = 0) {
3506
+ clearTimeout(lfKb.settle)
3507
+ if (from >= SETTLE_WHILE_TYPING_MS.length) return
3508
+ let i = from
3509
+ const step = () => {
3510
+ if (!isTypingTarget(document.activeElement)) return
3511
+ fitFullViewport()
3512
+ if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
3513
+ }
3514
+ lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
3515
+ }
3516
+ if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
3517
+ window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
3518
+ for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
3519
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
3520
+ document.addEventListener('focusin', (e) => {
3521
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3522
+ if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3523
+ // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3524
+ // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3525
+ settleWhileTyping(0)
3526
+ })
3527
+ // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3528
+ // goes down, whatever iOS reports or doesn't on the way.
3529
+ document.addEventListener('focusout', (e) => {
3530
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3531
+ clearTimeout(lfKb.settle)
3532
+ let i = 0
3533
+ const step = () => {
3534
+ fitFullViewport()
3535
+ if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
3536
+ if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
3537
+ else lfKb.scroll = null
3538
+ }
3539
+ lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
3540
+ })
3387
3541
  function fullAfterRender(sid) {
3388
3542
  const box = document.querySelector('.live-box.full')
3389
3543
  setFullClass(!!box)
@@ -3391,7 +3545,9 @@ function fullAfterRender(sid) {
3391
3545
  if (box) {
3392
3546
  const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
3393
3547
  const t = box.querySelector('.lf-title')
3394
- if (t) t.textContent = sessionTitle(s) // the session's name, as text
3548
+ if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
3549
+ const w = box.querySelector('.lf-where')
3550
+ 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
3395
3551
  paintLfTabs()
3396
3552
  const ol = box.querySelector('.live-log')
3397
3553
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
@@ -3628,11 +3784,40 @@ function composerFor(el) {
3628
3784
  const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
3629
3785
  return box && !box.classList.contains('off') ? box : null
3630
3786
  }
3787
+ // The picker the paperclip opens lives outside the page (0.9.18). A phone's
3788
+ // photo picker covers the board for seconds; coming back is a wake-up (focus
3789
+ // after more than AWAY_MS away) that redraws the page, and iOS hands the
3790
+ // picked photo over only after that, to the <input> it opened from. A picker
3791
+ // inside the composer was gone by then (the redraw made a new one), so its
3792
+ // change event went nowhere and the photo never showed. This one is never
3793
+ // redrawn; the composer's own hidden input stays for drops and tests.
3794
+ let attPicker = null
3795
+ function attachPicker() {
3796
+ if (attPicker && attPicker.isConnected) return attPicker
3797
+ attPicker = document.createElement('input')
3798
+ attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
3799
+ attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
3800
+ attPicker.setAttribute('aria-hidden', 'true')
3801
+ document.body.appendChild(attPicker)
3802
+ return attPicker
3803
+ }
3804
+ function openPicker(sid) {
3805
+ if (!sid) return
3806
+ const pk = attachPicker()
3807
+ pk.dataset.sid = sid
3808
+ pk.value = ''
3809
+ pk.click() // still inside the tap: iOS opens a picker only from a user gesture
3810
+ }
3811
+ function takePicked(inp) {
3812
+ const files = Array.from(inp.files || [])
3813
+ if (!files.length) return
3814
+ inp.value = '' // the same file can be picked again
3815
+ 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) }
3816
+ }
3631
3817
  document.addEventListener('change', (ev) => {
3632
3818
  const inp = ev.target
3633
3819
  if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
3634
- addFiles(inp.dataset.sid, inp.files)
3635
- inp.value = '' // the same file can be picked again
3820
+ takePicked(inp)
3636
3821
  })
3637
3822
  document.addEventListener('paste', (ev) => {
3638
3823
  const t = ev.target
@@ -4375,10 +4560,11 @@ async function aroundWire(id) {
4375
4560
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4376
4561
  }
4377
4562
  }
4378
- function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4563
+ // decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
4564
+ function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
4379
4565
  return list.map((cp) =>
4380
4566
  '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4381
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4567
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
4382
4568
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4383
4569
  filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4384
4570
  (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
@@ -4388,6 +4574,268 @@ function cpHeadLine(cp) {
4388
4574
  return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4389
4575
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4390
4576
  }
4577
+ // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4578
+ const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
4579
+ const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
4580
+ // 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
4581
+ const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
4582
+ const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
4583
+ // The Decided fold: every decision the session made, shown whenever there's at least one
4584
+ function panelDecidedFold(s) {
4585
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
4586
+ if (!list.length) return ''
4587
+ 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 })
4588
+ }
4589
+ // 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
4590
+ function noteSessionDecisions(s) {
4591
+ const repo = s && s.decisionRepo
4592
+ if (!repo || decisionsUi.loadedRepo[repo]) return
4593
+ decisionsUi.loadedRepo[repo] = true
4594
+ api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4595
+ decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
4596
+ const ps = panelSess()
4597
+ if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
4598
+ }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4599
+ }
4600
+ // ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
4601
+ const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
4602
+ const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4603
+ async function viewTestplan(arg, params) {
4604
+ clearTimeout(tpUi.timer)
4605
+ if (arg === 'new') {
4606
+ const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
4607
+ const key = JSON.stringify(q)
4608
+ if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
4609
+ paintTestplanNew()
4610
+ const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
4611
+ try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
4612
+ try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
4613
+ paintTestplanNew()
4614
+ const w = tpUi.nu.preview && tpUi.nu.preview.walking
4615
+ if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
4616
+ return
4617
+ }
4618
+ const machine = params.get('machine') || null
4619
+ if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
4620
+ tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
4621
+ paintTestplan()
4622
+ 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)
4623
+ }
4624
+ function paintTestplanNew() {
4625
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
4626
+ const n = tpUi.nu
4627
+ $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>' : '')
4628
+ }
4629
+ function paintTestplan() {
4630
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
4631
+ $app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
4632
+ }
4633
+ async function tpCheck(id, check, body) {
4634
+ 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)) }
4635
+ }
4636
+ document.addEventListener('click', async (ev) => {
4637
+ const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
4638
+ if (!b || b.tagName === 'FORM' || b.disabled) return
4639
+ const a = b.dataset.action
4640
+ if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
4641
+ if (a === 'tp-run' || a === 'tp-walk') {
4642
+ const n = tpUi.nu
4643
+ if (!n) return
4644
+ const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
4645
+ 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 }
4646
+ n.starting = true; paintTestplanNew()
4647
+ 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() }
4648
+ return
4649
+ }
4650
+ const id = b.dataset.id, check = b.dataset.check
4651
+ 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 }) }
4652
+ if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
4653
+ if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
4654
+ 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 }
4655
+ if (a === 'tp-download') {
4656
+ if (!tpUi.plan) return
4657
+ const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
4658
+ 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()
4659
+ setTimeout(() => URL.revokeObjectURL(url), 4000)
4660
+ return
4661
+ }
4662
+ 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)) } }
4663
+ })
4664
+ document.addEventListener('submit', async (ev) => {
4665
+ const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
4666
+ if (!f) return
4667
+ ev.preventDefault()
4668
+ if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
4669
+ if (f.dataset.action === 'tp-propose-save') {
4670
+ try {
4671
+ const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
4672
+ tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
4673
+ } catch (e) { toast(e.message || String(e)) }
4674
+ }
4675
+ })
4676
+ // 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
4677
+ // count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
4678
+ function paintPanelDecided(s) {
4679
+ const slot = document.getElementById('fsDecided')
4680
+ if (!slot || !s) return
4681
+ const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
4682
+ const fold = slot.querySelector('[data-fold="cp-decided"]')
4683
+ if (!list.length) { if (fold) slot.innerHTML = ''; return }
4684
+ if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
4685
+ const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
4686
+ if (n && n.textContent !== cnt) n.textContent = cnt
4687
+ const box = fold.querySelector('.fs-fold-b')
4688
+ const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
4689
+ if (box && box.innerHTML !== html) box.innerHTML = html
4690
+ // the Timeline's older checkpoints read the same log
4691
+ const by = sessionLogBy(s)
4692
+ if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
4693
+ const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
4694
+ if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
4695
+ }
4696
+ }
4697
+ // 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
4698
+ function refreshPanelDecisions(s) {
4699
+ if (!s || !s.decisionRepo) return
4700
+ decisionsUi.loadedRepo[s.decisionRepo] = false
4701
+ noteSessionDecisions(s)
4702
+ }
4703
+ // ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
4704
+ async function viewDecisions(arg) {
4705
+ const raw = decodeURIComponent(arg || '')
4706
+ if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
4707
+ decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
4708
+ const want = state.route && state.route.params && state.route.params.get('d')
4709
+ if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
4710
+ paintDecisions()
4711
+ 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) } }
4712
+ }
4713
+ function paintDecisions() {
4714
+ if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
4715
+ $app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
4716
+ if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
4717
+ }
4718
+ async function decisionAction(repo, ref, action, body = {}) {
4719
+ try {
4720
+ const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
4721
+ decisionsUi.loadedRepo = {}
4722
+ // a walkthrough citing it shows its new word (Proposed goes once accepted)
4723
+ const dd = r && r.decision
4724
+ 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 = '' }
4725
+ if (wtUi.sid) wtFill(wtSession(wtUi.sid))
4726
+ const dv = document.querySelector('.dv-isdec:not([hidden])')
4727
+ if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
4728
+ if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
4729
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4730
+ else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
4731
+ } catch (e) { toast(e.message || String(e)) }
4732
+ }
4733
+ document.addEventListener('click', async (ev) => {
4734
+ const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
4735
+ if (!btn || btn.tagName === 'FORM') return
4736
+ const a = btn.dataset.action
4737
+ if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
4738
+ if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
4739
+ 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() }
4740
+ if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4741
+ if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
4742
+ 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 }
4743
+ if (a === 'dec-show') {
4744
+ const el = document.getElementById('dec-' + btn.dataset.id)
4745
+ 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 }
4746
+ if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
4747
+ return
4748
+ }
4749
+ if (a === 'dec-store') {
4750
+ const on = btn.dataset.on === 'true'
4751
+ try {
4752
+ await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
4753
+ 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')
4754
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4755
+ else loadProjectDecisions(qaUi.token, true)
4756
+ } catch (e) { toast(e.message || String(e)) }
4757
+ }
4758
+ })
4759
+ // A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
4760
+ document.addEventListener('click', (ev) => {
4761
+ const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
4762
+ if (!b) return
4763
+ ev.preventDefault()
4764
+ const s = wtSession(wtUi.sid)
4765
+ openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
4766
+ })
4767
+ // 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
4768
+ const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4769
+ let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
4770
+ async function openDecisionViewer(repo, id, from, ctx = {}) {
4771
+ if (!repo || !id) return
4772
+ decViewerCtx = { repo, id, from, ctx }
4773
+ let v
4774
+ try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
4775
+ const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
4776
+ const d = byId.get(id)
4777
+ const el = docViewerEl()
4778
+ el.querySelector('.dv-title').textContent = 'Decision ' + id
4779
+ el.querySelector('.dv-dl').hidden = true
4780
+ el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
4781
+ el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
4782
+ // the chain: what it replaced (oldest first), the decision, what replaced it
4783
+ const chain = []
4784
+ if (d) {
4785
+ const back = []; let cur = d, guard = 0
4786
+ 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 }
4787
+ chain.push(...back, d)
4788
+ cur = d; guard = 0
4789
+ while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
4790
+ }
4791
+ // 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
4792
+ // 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
4793
+ const trusted = decisionsTrusted()
4794
+ const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
4795
+ let offer = ''
4796
+ if (d && ctx.departs && ctx.sessionId) {
4797
+ const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
4798
+ 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">' +
4799
+ 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>'
4800
+ }
4801
+ 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 +
4802
+ '<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
4803
+ el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
4804
+ refUi.from = from || null
4805
+ el.hidden = false
4806
+ document.documentElement.classList.add('dv-open')
4807
+ const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
4808
+ }
4809
+ document.addEventListener('submit', (ev) => {
4810
+ const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
4811
+ if (!f) return
4812
+ ev.preventDefault()
4813
+ const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
4814
+ decisionsUi.editing = null
4815
+ decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4816
+ })
4817
+ // The project page's Decisions section
4818
+ // (2026-11-05) or a single repo's page: that repo's log (the page names its id)
4819
+ const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
4820
+ async function loadProjectDecisions(token, force = false) {
4821
+ if (!token) return
4822
+ const scopeArg = pageDecisionScope(token)
4823
+ if (!scopeArg) return
4824
+ try {
4825
+ const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
4826
+ decisionsUi.project = { token, v, scopeArg }
4827
+ paintProjectDecisions()
4828
+ } catch { decisionsUi.project = null }
4829
+ }
4830
+ function paintProjectDecisions() {
4831
+ const box = document.getElementById('projDecisionsBox')
4832
+ const pd = decisionsUi.project
4833
+ if (!box || !pd || pd.token !== qaUi.token) return
4834
+ const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
4835
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
4836
+ const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
4837
+ if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
4838
+ }
4391
4839
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4392
4840
  // your note and the files behind More (open or closed as you last left it on this device).
4393
4841
  function cpLatestHtml(cp, wtSid, paths) {
@@ -4415,10 +4863,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
4415
4863
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
4416
4864
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
4417
4865
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
4418
- const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
4866
+ 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
4419
4867
  // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
4420
4868
  // tap repaints from what we have and reads again only when that is old.
4421
- 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 } }
4869
+ 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 } }
4422
4870
  function paintPrs(d) {
4423
4871
  const q = document.getElementById('prs-q')
4424
4872
  const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
@@ -4567,7 +5015,8 @@ function briefAction(id, sessions, viewer) {
4567
5015
 
4568
5016
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4569
5017
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4570
- 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)
5018
+ // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
5019
+ 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)
4571
5020
  const out = []
4572
5021
  let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4573
5022
  const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
@@ -5110,6 +5559,7 @@ function walkValueOf(w) {
5110
5559
  const s = w.saved || null
5111
5560
  const choice = s ? s.choice : w.choice
5112
5561
  if (choice === 'agent') return 'agent'
5562
+ if (choice === 'codex') return 'codex' // 0.9.17
5113
5563
  if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
5114
5564
  if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
5115
5565
  return 'summarizer'
@@ -5151,7 +5601,7 @@ function paintWalk() {
5151
5601
  let h = '', h0 = ''
5152
5602
  // first: this session's own agent (recommended where Claude Code is here)
5153
5603
  const ag = opt('agent')
5154
- const fbLine = d.fallback ? 'When a session can’t be forked (a Codex session, say), ' + d.fallback.label + ' writes it.' : ''
5604
+ const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
5155
5605
  h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
5156
5606
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
5157
5607
  const cl = opt('claude')
@@ -5166,6 +5616,10 @@ function paintWalk() {
5166
5616
  h += h0
5167
5617
  h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
5168
5618
  side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
5619
+ // 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
5620
+ const cx = opt('codex')
5621
+ h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
5622
+ side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
5169
5623
  const smo = opt('summarizer')
5170
5624
  h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
5171
5625
  const ol = d.ollama || {}
@@ -6586,6 +7040,8 @@ async function render() {
6586
7040
  else if (r.name === 'search') await viewSearch(r.q || '')
6587
7041
  else if (r.name === 'new') await viewNewSession()
6588
7042
  else if (r.name === 'project' && r.arg) await viewProject(r.arg)
7043
+ else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
7044
+ else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
6589
7045
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6590
7046
  else if (r.name === 'setup') await viewSetup()
6591
7047
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6620,6 +7076,7 @@ async function viewProject(token) {
6620
7076
  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
6621
7077
  qaUi.page = page
6622
7078
  paintProject()
7079
+ if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
6623
7080
  if (fresh) window.scrollTo(0, 0)
6624
7081
  const r = state.route || {}
6625
7082
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6639,6 +7096,11 @@ function paintProject() {
6639
7096
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6640
7097
  $app.innerHTML = out.html
6641
7098
  fillAsk($app, out.keys)
7099
+ 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
7100
+ const at = document.getElementById('projAsks')
7101
+ if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
7102
+ paintProjectDecisions()
7103
+ }
6642
7104
  const ta = document.getElementById('askIn')
6643
7105
  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 {} } }
6644
7106
  }
@@ -7171,9 +7633,9 @@ async function startSend(btn) {
7171
7633
  const box = btn.closest('.start-box')
7172
7634
  const ta = box && box.querySelector('.start-text')
7173
7635
  const text = ta ? ta.value.trim() : ''
7174
- if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
7175
- if (startUi.sending) return
7176
7636
  const pick = startPick(startUi.data, startUi)
7637
+ if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
7638
+ if (startUi.sending) return
7177
7639
  if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
7178
7640
  startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
7179
7641
  try {
@@ -7182,14 +7644,21 @@ async function startSend(btn) {
7182
7644
  await uploadsSettled(START_SID)
7183
7645
  const atts = attachList(START_SID)
7184
7646
  if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
7185
- const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
7647
+ const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
7186
7648
  lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
7187
7649
  if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
7188
7650
  const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
7189
7651
  if (state.drafts) delete state.drafts.start
7190
7652
  if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
7191
- if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text); return }
7192
- startUi.ask = { ...r, machine: r.machine || pick.m.machine }
7653
+ if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
7654
+ if (r.state === 'started' && r.sessionId) {
7655
+ startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
7656
+ if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
7657
+ return
7658
+ }
7659
+ // 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
7660
+ if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
7661
+ startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
7193
7662
  pollStartAsk()
7194
7663
  } catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
7195
7664
  if (state.route && state.route.name === 'new') render()
@@ -8471,6 +8940,7 @@ document.addEventListener('click', async (ev) => {
8471
8940
  if (action === 'wt-close') return wtClose()
8472
8941
  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 }
8473
8942
  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) }
8943
+ if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8474
8944
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8475
8945
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
8476
8946
  if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
@@ -8501,7 +8971,7 @@ document.addEventListener('click', async (ev) => {
8501
8971
  const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
8502
8972
  return
8503
8973
  }
8504
- if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
8974
+ 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 }) }
8505
8975
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8506
8976
  // 2026-10-30 walkthrough round 3
8507
8977
  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)) }
@@ -8561,6 +9031,7 @@ document.addEventListener('click', async (ev) => {
8561
9031
  // 0.9.8 What it did
8562
9032
  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 }
8563
9033
  if (action === 'wid-refresh') return loadWid(id)
9034
+ if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
8564
9035
  if (action === 'wid-tool') {
8565
9036
  const ui = widOf(id), t = btn.dataset.tool
8566
9037
  ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
@@ -8601,6 +9072,8 @@ document.addEventListener('click', async (ev) => {
8601
9072
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
8602
9073
  if (action === 'pr-expand') return togglePr(btn.dataset.url)
8603
9074
  if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
9075
+ 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
9076
+ if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
8604
9077
  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
8605
9078
  if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
8606
9079
  if (action === 'pr-unlink') {
@@ -8742,7 +9215,7 @@ document.addEventListener('click', async (ev) => {
8742
9215
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
8743
9216
  if (action === 'start-pick') {
8744
9217
  const k = btn.dataset.k, v = btn.dataset.v
8745
- if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
9218
+ if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
8746
9219
  return render()
8747
9220
  }
8748
9221
  // 0.9.16 + Add another repo, and Save these as a project…
@@ -8821,6 +9294,21 @@ document.addEventListener('click', async (ev) => {
8821
9294
  return render()
8822
9295
  }
8823
9296
  if (action === 'ask-auto') return autoRestClick(btn)
9297
+ // 1.0 Continue in terminal / Continue headless (src/run-switch.js)
9298
+ if (action === 'run-continue') {
9299
+ const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
9300
+ if (!id || hlPending.has(id) || switchPending.has(id)) return
9301
+ switchPending.add(id); btn.disabled = true
9302
+ toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
9303
+ try {
9304
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
9305
+ toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
9306
+ if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
9307
+ } catch (e) { toast(e.message, 6000) }
9308
+ switchPending.delete(id)
9309
+ repaintSession(id)
9310
+ return render()
9311
+ }
8824
9312
  if (action === 'headless-stop' || action === 'headless-run') {
8825
9313
  const op = action === 'headless-stop' ? 'stop' : 'resume'
8826
9314
  if (!id || hlPending.has(id)) return
@@ -9114,7 +9602,7 @@ document.addEventListener('click', async (ev) => {
9114
9602
  if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
9115
9603
  if (action === 'lv-queued-now') return sendNowClick(btn)
9116
9604
  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 }
9117
- if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
9605
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
9118
9606
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
9119
9607
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
9120
9608
  if (action === 'att-open') return openAttachment(btn)
@@ -9454,7 +9942,7 @@ function docViewerEl() {
9454
9942
  document.body.appendChild(el)
9455
9943
  return el
9456
9944
  }
9457
- 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') }
9945
+ 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') }
9458
9946
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9459
9947
  async function openDoc(sessionId, { path, plan } = {}) {
9460
9948
  if (!sessionId || (!path && !plan)) return
@@ -9721,6 +10209,9 @@ async function needDismissClick(btn) {
9721
10209
 
9722
10210
  // Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
9723
10211
  function goClick(btn, id, s) {
10212
+ // 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
10213
+ // 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
10214
+ if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
9724
10215
  return goWithFeedback({
9725
10216
  id,
9726
10217
  title: s && s.title,