@leftoff/cli 0.9.18-beta.1 → 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'
@@ -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.
@@ -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,7 +1458,7 @@ 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) {
@@ -1772,6 +1825,9 @@ function repoRow(shown, f, tail = '') {
1772
1825
  return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
1773
1826
  }
1774
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' }) : '' }
1775
1831
  // The repo chip row, shared by the board and the timeline: All, the top 8,
1776
1832
  // and More ▾ with a filter box. chips: repoChips() output.
1777
1833
  function chipBar(chips, selected, allCount, emptyNote, tail = '') {
@@ -1782,7 +1838,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1782
1838
  // with nothing in this view: a filter is never active out of sight.
1783
1839
  chips = orderSelectedFirst(chips, selected)
1784
1840
  const { shown: row, more } = splitChips(chips, selected, 8)
1785
- const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
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>' }
1786
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>'
1787
1843
  let menu = ''
1788
1844
  if (more.length) {
@@ -1796,13 +1852,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
1796
1852
  // ------------------------------------------------------------ projects (0.9.0)
1797
1853
  // A project is a named group of repos (projects.json in the synced folder).
1798
1854
  // Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
1799
- const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
1855
+ const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
1800
1856
  async function loadProjects({ fresh = false } = {}) {
1801
1857
  if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
1802
- try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
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() }
1803
1859
  return projectsUi
1804
1860
  }
1805
- 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() } }
1806
1862
  // The small chip next to the repo: tap = show only this project.
1807
1863
  function projChip(s) {
1808
1864
  const p = s && s.repoProject
@@ -1865,7 +1921,7 @@ function projectRow(list, sel, action, counts, tail = '') {
1865
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>' : ''
1866
1922
  // the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
1867
1923
  return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
1868
- chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
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>'
1869
1925
  }
1870
1926
  // 0.9.12: the board's own way in to a project with several repos: a "New
1871
1927
  // project" button at the end of the project row (or the repo row before there
@@ -2542,9 +2598,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
2542
2598
  const SESSION_CACHE_MAX = 8
2543
2599
  function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
2544
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
+ }
2545
2623
  function sessionShellHtml(s) {
2546
2624
  const head = s
2547
- ? '<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>'
2548
2626
  : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
2549
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>' +
2550
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>' +
@@ -2750,7 +2828,7 @@ async function loadWid(id) {
2750
2828
  paintWid(id)
2751
2829
  await Promise.all([
2752
2830
  api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
2753
- api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2831
+ api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
2754
2832
  ])
2755
2833
  ui.loading = false
2756
2834
  // a file you had open shows its lines again
@@ -2762,7 +2840,7 @@ async function loadWidFile(id, p) {
2762
2840
  const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
2763
2841
  if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
2764
2842
  ui.fileLoading[p] = true; paintWid(id)
2765
- try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
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) } }
2766
2844
  delete ui.fileLoading[p]
2767
2845
  paintWid(id)
2768
2846
  }
@@ -2850,8 +2928,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2850
2928
  // title is being made, its spinner sits in the cluster.
2851
2929
  const fullTitle = sessionTitle(s)
2852
2930
  const title = canRename
2853
- ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2854
- : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
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>'
2855
2933
  const menu = fsMenuItems(s, mine)
2856
2934
  const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2857
2935
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
@@ -2861,7 +2939,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2861
2939
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2862
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>' +
2863
2941
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2864
- '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
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>' +
2865
2943
  // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2866
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>' : '') +
2867
2945
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
@@ -2895,7 +2973,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2895
2973
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2896
2974
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2897
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>'
2898
- if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
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 })
2899
2981
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2900
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>'
2901
2983
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
@@ -3379,21 +3461,83 @@ function setFullClass(on) {
3379
3461
  applyNav()
3380
3462
  }
3381
3463
  // The window as you see it: on a phone the keyboard takes the bottom of it,
3382
- // and the composer stays just above the keyboard.
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)
3383
3468
  function fitFullViewport() {
3384
3469
  const vv = window.visualViewport
3385
- const st = document.documentElement.style
3386
- if (!document.documentElement.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); return }
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 }
3387
3472
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3388
3473
  const strip = document.getElementById('tabStrip')
3389
3474
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3390
3475
  st.setProperty('--lf-bar', bar + 'px')
3391
- if (!vv) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); return }
3392
- st.setProperty('--lf-h', Math.round(vv.height) + 'px')
3393
- st.setProperty('--lf-top', Math.round(vv.offsetTop) + 'px')
3394
- document.documentElement.classList.toggle('lf-kb', window.innerHeight - vv.height > 120) // the keyboard is up: no home-indicator gap under the composer
3395
- }
3396
- if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
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
+ })
3397
3541
  function fullAfterRender(sid) {
3398
3542
  const box = document.querySelector('.live-box.full')
3399
3543
  setFullClass(!!box)
@@ -3401,7 +3545,9 @@ function fullAfterRender(sid) {
3401
3545
  if (box) {
3402
3546
  const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
3403
3547
  const t = box.querySelector('.lf-title')
3404
- 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
3405
3551
  paintLfTabs()
3406
3552
  const ol = box.querySelector('.live-log')
3407
3553
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
@@ -3638,11 +3784,40 @@ function composerFor(el) {
3638
3784
  const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
3639
3785
  return box && !box.classList.contains('off') ? box : null
3640
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
+ }
3641
3817
  document.addEventListener('change', (ev) => {
3642
3818
  const inp = ev.target
3643
3819
  if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
3644
- addFiles(inp.dataset.sid, inp.files)
3645
- inp.value = '' // the same file can be picked again
3820
+ takePicked(inp)
3646
3821
  })
3647
3822
  document.addEventListener('paste', (ev) => {
3648
3823
  const t = ev.target
@@ -4385,10 +4560,11 @@ async function aroundWire(id) {
4385
4560
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4386
4561
  }
4387
4562
  }
4388
- 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) {
4389
4565
  return list.map((cp) =>
4390
4566
  '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4391
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4567
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
4392
4568
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4393
4569
  filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4394
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
@@ -4398,6 +4574,268 @@ function cpHeadLine(cp) {
4398
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>' +
4399
4575
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4400
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
+ }
4401
4839
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4402
4840
  // your note and the files behind More (open or closed as you last left it on this device).
4403
4841
  function cpLatestHtml(cp, wtSid, paths) {
@@ -4425,10 +4863,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
4425
4863
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
4426
4864
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
4427
4865
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
4428
- const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
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
4429
4867
  // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
4430
4868
  // tap repaints from what we have and reads again only when that is old.
4431
- function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
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 } }
4432
4870
  function paintPrs(d) {
4433
4871
  const q = document.getElementById('prs-q')
4434
4872
  const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
@@ -4577,7 +5015,8 @@ function briefAction(id, sessions, viewer) {
4577
5015
 
4578
5016
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4579
5017
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4580
- const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
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)
4581
5020
  const out = []
4582
5021
  let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4583
5022
  const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
@@ -6601,6 +7040,8 @@ async function render() {
6601
7040
  else if (r.name === 'search') await viewSearch(r.q || '')
6602
7041
  else if (r.name === 'new') await viewNewSession()
6603
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
6604
7045
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6605
7046
  else if (r.name === 'setup') await viewSetup()
6606
7047
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6635,6 +7076,7 @@ async function viewProject(token) {
6635
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
6636
7077
  qaUi.page = page
6637
7078
  paintProject()
7079
+ if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
6638
7080
  if (fresh) window.scrollTo(0, 0)
6639
7081
  const r = state.route || {}
6640
7082
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6654,6 +7096,11 @@ function paintProject() {
6654
7096
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6655
7097
  $app.innerHTML = out.html
6656
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
+ }
6657
7104
  const ta = document.getElementById('askIn')
6658
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 {} } }
6659
7106
  }
@@ -8493,6 +8940,7 @@ document.addEventListener('click', async (ev) => {
8493
8940
  if (action === 'wt-close') return wtClose()
8494
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 }
8495
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 }) }
8496
8944
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8497
8945
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
8498
8946
  if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
@@ -8523,7 +8971,7 @@ document.addEventListener('click', async (ev) => {
8523
8971
  const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
8524
8972
  return
8525
8973
  }
8526
- 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 }) }
8527
8975
  if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
8528
8976
  // 2026-10-30 walkthrough round 3
8529
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)) }
@@ -8583,6 +9031,7 @@ document.addEventListener('click', async (ev) => {
8583
9031
  // 0.9.8 What it did
8584
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 }
8585
9033
  if (action === 'wid-refresh') return loadWid(id)
9034
+ if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
8586
9035
  if (action === 'wid-tool') {
8587
9036
  const ui = widOf(id), t = btn.dataset.tool
8588
9037
  ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
@@ -8623,6 +9072,8 @@ document.addEventListener('click', async (ev) => {
8623
9072
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
8624
9073
  if (action === 'pr-expand') return togglePr(btn.dataset.url)
8625
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() }
8626
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
8627
9078
  if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
8628
9079
  if (action === 'pr-unlink') {
@@ -9151,7 +9602,7 @@ document.addEventListener('click', async (ev) => {
9151
9602
  if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
9152
9603
  if (action === 'lv-queued-now') return sendNowClick(btn)
9153
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 }
9154
- 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 }
9155
9606
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
9156
9607
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
9157
9608
  if (action === 'att-open') return openAttachment(btn)
@@ -9491,7 +9942,7 @@ function docViewerEl() {
9491
9942
  document.body.appendChild(el)
9492
9943
  return el
9493
9944
  }
9494
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
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') }
9495
9946
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9496
9947
  async function openDoc(sessionId, { path, plan } = {}) {
9497
9948
  if (!sessionId || (!path && !plan)) return