@leftoff/cli 0.9.17-beta.7 → 0.9.17-beta.9

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.17-beta.7",
3
+ "version": "0.9.17-beta.9",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -1672,7 +1672,9 @@ export function joinListLines(t) {
1672
1672
  // last error, edited files not yet committed) or empty.
1673
1673
  export function heuristicCheckpoint(messages, session = {}, { length = 'short', cwd } = {}) {
1674
1674
  const ms_ = (messages || []).map((m) => (m && m.role === 'user' && typeof m.text === 'string' && /<\/?channel\b/i.test(m.text) ? { ...m, text: stripChannel(m.text) } : m))
1675
- const lastUser = [...ms_].reverse().find((m) => m.role === 'user' && m.text && m.text.trim())
1675
+ // 2026-10-29: Claude Code's own wrappers (<task-notification>, <system-reminder>…) aren't what you asked for,
1676
+ // so they never become "Working on: “<task-notification>…”"
1677
+ const lastUser = [...ms_].reverse().find((m) => m.role === 'user' && m.text && m.text.trim() && !NOT_A_PROMPT.test(m.text))
1676
1678
  const lastAssistant = [...ms_].reverse().find((m) => m.role === 'assistant' && m.text && m.text.trim())
1677
1679
  let lastError = null
1678
1680
  for (let i = ms_.length - 1; i >= 0 && !lastError; i--) {
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.17-beta.7' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.17-beta.9' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.17-beta.7",
3
+ "version": "0.9.17-beta.9",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -5102,3 +5102,110 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
5102
5102
  .ghost .ghost-k { flex: none; }
5103
5103
  .ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
5104
5104
  .ghost .ghost-k { align-self: center; }
5105
+
5106
+ /* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
5107
+ .hostedby { min-width: 0; }
5108
+ .hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
5109
+ .host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
5110
+ .host-row + .host-row { border-top: 1px solid var(--line); }
5111
+ .host-row.is-host { background: var(--bg2); }
5112
+ .host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
5113
+ .host-row.on .host-shape { color: var(--text); }
5114
+ .host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
5115
+ .host-sub { flex-basis: 100%; font-size: 12.5px; }
5116
+ .host-chip { font-weight: 600; }
5117
+ .pref-chip { border-style: dashed; }
5118
+ .host-make { flex: none; min-height: 44px; }
5119
+ .host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
5120
+ .hostedby > .hint { margin-top: 8px; }
5121
+ .host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
5122
+ .hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
5123
+ .hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
5124
+ .hp-opt.on::before { content: '✓ '; }
5125
+ .hp-opt:disabled { opacity: .55; cursor: default; }
5126
+ .hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5127
+ @media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
5128
+ .relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
5129
+ .relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
5130
+ .relay-host + .relay-host { border-top: 1px solid var(--line); }
5131
+ .relay-host-shape { width: 16px; text-align: center; flex: none; }
5132
+ .relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
5133
+ .relay-use { min-height: 44px; margin-left: auto; }
5134
+ @media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
5135
+
5136
+ /* 2026-10-29 a calmer session panel (ui/side-fold.js): a slim pinned head, then the top open (Catch me up,
5137
+ the newest checkpoint's Stopped + Next, PRs, To do), the rest one-line headers with counts, closed until opened. */
5138
+ .fs-side-in > .fs-head { position: sticky; top: -10px; z-index: 4; margin: -10px -16px 4px; padding: 10px 16px 0; background: var(--bg); }
5139
+ .fs-side.is-scrolled .fs-side-in > .fs-head { padding-top: 2px; box-shadow: 0 1px 0 var(--line); }
5140
+ .fs-side.is-scrolled .fs-head .s-title-text { -webkit-line-clamp: 1; line-clamp: 1; }
5141
+ .fs-side.is-scrolled .fs-head-row { margin: 0; }
5142
+ .fs-side.is-scrolled .fs-head .sub { flex-wrap: nowrap; overflow: hidden; margin: 0 0 2px; gap: 5px; }
5143
+ .fs-side.is-scrolled .fs-head .sub > * { flex: none; height: 20px; }
5144
+ .fs-side.is-scrolled .fs-head .sub > .s-tsrc { display: none; }
5145
+ @media (max-width: 600px) { .fs-side-in > .fs-head { position: static; margin: 0 0 4px; padding: 0; box-shadow: none; background: none; } } /* the sheet's own head (the peek) is what stays */
5146
+ .fs-fold { border-top: 1px solid var(--line); margin: 0; }
5147
+ .fs-fold > .fs-fold-h { list-style: none; display: flex; align-items: center; gap: 6px; min-height: 44px; margin: 0; padding: 0 2px; cursor: pointer; font-size: 13.5px; font-weight: 600; line-height: 1; color: var(--text2); border-radius: 6px; -webkit-tap-highlight-color: transparent; }
5148
+ @media (pointer: fine) { .fs-fold > .fs-fold-h { min-height: 34px; } }
5149
+ .fs-fold > .fs-fold-h::-webkit-details-marker { display: none; }
5150
+ .fs-fold > .fs-fold-h::marker { content: ''; }
5151
+ .fs-fold-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5152
+ .fs-fold-n { font-weight: 400; color: var(--muted); }
5153
+ .fs-fold-c { flex: none; width: 14px; height: 14px; color: var(--faint); transition: transform .15s ease; }
5154
+ .fs-fold[open] > .fs-fold-h .fs-fold-c { transform: rotate(90deg); }
5155
+ @media (hover: hover) { .fs-fold > .fs-fold-h:hover { color: var(--text); } }
5156
+ .fs-fold > .fs-fold-h:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
5157
+ .fs-fold-b { padding: 2px 0 12px; }
5158
+ .fs-fold-b > .sfiles > h2:first-child { display: none; }
5159
+ .fs-fold-b > .slists > h2:first-child { margin-top: 0; }
5160
+ .fs-fold-b > .park-row, .fs-fold-b > .spacepick { margin-top: 0; }
5161
+ .fs-fold.fs-fold-in { border-top: 0; margin-top: 6px; }
5162
+ .fs-fold.fs-fold-in > .fs-fold-h { font-weight: 500; font-size: 13px; color: var(--muted); }
5163
+ @media (prefers-reduced-motion: reduce) { .fs-fold-c { transition: none; } }
5164
+ /* the newest checkpoint: Stopped + Next; More holds the rest */
5165
+ .fs-cp-more > summary { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; }
5166
+ @media (pointer: coarse) { .fs-cp-more > summary { min-height: 44px; } }
5167
+ .fs-cp-more[open] > summary { margin-bottom: 4px; }
5168
+ .files .files-more { display: contents; }
5169
+ .files .files-more > summary { list-style: none; display: inline-flex; align-items: center; height: 24px; line-height: 1; padding: 0 6px; font-size: 12px; cursor: pointer; color: var(--accent); }
5170
+ .files .files-more > summary::-webkit-details-marker { display: none; }
5171
+ .files .files-more[open] > summary { display: none; }
5172
+ .files .chip.mono { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5173
+ /* status history: one small shape per change; the word and time in its tip */
5174
+ .sstrip { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; padding: 2px 0; }
5175
+ .sstrip .ss { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; line-height: 1; font-size: 10px; border-radius: 4px; background: none; cursor: default; }
5176
+ .sstrip .ss:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
5177
+ /* answered: the newest three, Show all N */
5178
+ .sanswered .answered { margin: 4px 0; }
5179
+ .answered-more > summary { list-style: none; display: inline-flex; align-items: center; min-height: 32px; font-size: 12.5px; cursor: pointer; color: var(--accent); }
5180
+ .answered-more > summary::-webkit-details-marker { display: none; }
5181
+ .answered-more[open] > summary { display: none; }
5182
+ @media (pointer: coarse) { .answered-more > summary { min-height: 44px; } }
5183
+ /* PRs: one compact row each (state, #, the title on one line); tap the title for the rest */
5184
+ .pr-compact .prrow { padding: 0 0 0 10px; gap: 6px; margin-bottom: 6px; min-height: 44px; }
5185
+ @media (pointer: fine) { .pr-compact .prrow { min-height: 34px; } }
5186
+ .pr-compact .prrow.open { padding-top: 8px; padding-bottom: 8px; align-items: flex-start; }
5187
+ .pr-compact .pr-main { flex: 1 1 auto; min-width: 0; }
5188
+ .pr-compact .pr-c1 { display: flex; align-items: center; gap: 7px; min-width: 0; height: 32px; font-size: 13px; line-height: 1; }
5189
+ .pr-compact .pr-c1 .pr-state, .pr-compact .pr-c1 .pr-num { flex: none; font-size: 12.5px; }
5190
+ .pr-compact .pr-c1 .pr-num { color: var(--muted); font-variant-numeric: tabular-nums; }
5191
+ .pr-compact .pr-title { flex: 1 1 auto; min-width: 0; margin: 0; overflow: hidden; }
5192
+ .pr-compact .pr-title-btn { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 500; color: var(--text); }
5193
+ .pr-compact .pr-meta { margin-top: 2px; font-size: 12px; }
5194
+ .pr-compact .pr-actions { margin: 0 -2px 0 0; }
5195
+ @media (pointer: fine) { .pr-compact .pr-actions .pr-ic { width: 32px; height: 32px; } }
5196
+ .sprs .pr-unfold { margin: 0 0 8px; }
5197
+ .sprs .pr-unfold > .pr-unsum { list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; line-height: 1; padding: 0 10px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
5198
+ @media (pointer: coarse) { .sprs .pr-unfold > .pr-unsum { height: 44px; } }
5199
+ .sprs .pr-unfold > .pr-unsum::-webkit-details-marker { display: none; }
5200
+ .sprs .pr-unfold > .pr-unsum:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5201
+ .sprs .pr-unfold[open] > .pr-unsum { margin-bottom: 6px; }
5202
+ .sprs .pr-linkd > summary { list-style: none; display: inline-flex; align-items: center; min-height: 32px; font-size: 12.5px; cursor: pointer; color: var(--accent); }
5203
+ @media (pointer: coarse) { .sprs .pr-linkd > summary { min-height: 44px; } }
5204
+ .sprs .pr-linkd > summary::-webkit-details-marker { display: none; }
5205
+ .sprs .pr-linkd[open] > summary { display: none; }
5206
+ /* a path the board can't read: plain monospace, why in its tip (never a viewer that opens onto "not available") */
5207
+ .file-ref-off { font-family: var(--mono); font-size: .94em; color: var(--text2); cursor: default; overflow-wrap: anywhere; }
5208
+ code .file-ref-off { font-size: inherit; }
5209
+ .file-ref-off:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 3px; }
5210
+ .fs-side-in > .wid { border-top: 1px solid var(--line); margin-top: 4px; }
5211
+ .fs-side-in > .wid .wid-toggle h2 { text-transform: none; letter-spacing: normal; font-size: 13.5px; font-weight: 600; color: var(--text2); }
package/ui/app.js CHANGED
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
34
34
  import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
35
35
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
36
36
  import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
37
+ import { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
37
38
  import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
38
39
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
39
40
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
@@ -51,6 +52,8 @@ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
51
52
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
52
53
  import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
53
54
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
55
+ import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
56
+ import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
54
57
  import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName } from './semdiff.js' // 0.9.17 Walk me through it
55
58
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
56
59
  import { lastReply, ghostShows } from './ghost.js'
@@ -334,6 +337,8 @@ const ORDER = [
334
337
  ['stale', 'Stale'],
335
338
  ['ended', 'Ended today'],
336
339
  ]
340
+ const sideFolds = createFoldStore(typeof localStorage !== 'undefined' ? localStorage : null) // 2026-10-29 the session panel's folds (ui/side-fold.js)
341
+ let fsScrolled = null // the session panel scrolled past its head (its id): a redraw keeps the slim pinned head
337
342
  const LABEL = { needs_input: 'needs you', active: 'active', background: 'tasks running', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
338
343
 
339
344
  // ------------------------------------------------------------ helpers
@@ -467,14 +472,14 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
467
472
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
468
473
  const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
469
474
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
470
- function cpExtras(cp) {
475
+ function cpExtras(cp, paths = null) {
471
476
  let h = ''
472
477
  for (const [k, title] of EXTRA_TITLES) {
473
478
  const v = cp[k]
474
479
  if (!v || (Array.isArray(v) && !v.length)) continue
475
480
  h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
476
481
  if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
477
- else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
482
+ else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(paths ? relPath(f.path, paths) : f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
478
483
  else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
479
484
  h += '</div>'
480
485
  }
@@ -485,7 +490,9 @@ function cpExtras(cp) {
485
490
  // md (0.9.5): the session page's full view renders a markdown checkpoint (a
486
491
  // model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
487
492
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
488
- function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null } = {}) {
493
+ // part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
494
+ // paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
495
+ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
489
496
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
490
497
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
491
498
  const you = /^YOU:/i.test(cp.next || '')
@@ -499,6 +506,8 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
499
506
  }
500
507
  // 0.9.15: what the session is for and how far along, above its latest step
501
508
  const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
509
+ if (part === 'tail') return '<dl class="cp cp-tail' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '"><dt>Stopped</dt>' + dd(cp.stoppedAt) + (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') + '</dl>'
510
+ if (part === 'head') return (goal || String(cp.doing || '').trim() ? '<dl class="cp cp-head">' + (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') + (String(cp.doing || '').trim() ? '<dt>Doing</dt>' + dd(cp.doing) : '') + '</dl>' : '') + (n ? '<div class="cpx">' + cpExtras(cp, paths) + '</div>' : '')
502
511
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
503
512
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
504
513
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
@@ -506,7 +515,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
506
515
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
507
516
  (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>' : '') +
508
517
  '</dl>' +
509
- (more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp) + '</div></details>' : '')
518
+ (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>' : '')
510
519
  }
511
520
 
512
521
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -2067,7 +2076,7 @@ async function viewWaiting() {
2067
2076
  (recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
2068
2077
  (r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
2069
2078
  '<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
2070
- (r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
2079
+ (r.where ? '<span class="where">' + esc(whereWords(r.where)) + '</span>' : '') + '</li>').join('') + '</ul>'
2071
2080
  : '<div class="nocp">Nothing yet since the daemon started.</div>') +
2072
2081
  '<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
2073
2082
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
@@ -2149,13 +2158,10 @@ document.addEventListener('keydown', (ev) => {
2149
2158
  const b = document.querySelector('[data-action="agent-add"]')
2150
2159
  if (b && !b.disabled) b.click()
2151
2160
  })
2161
+ // 2026-10-29: the newest three, then Show all N; where each came from in true words (ui/side-fold.js)
2152
2162
  function answeredList(s) {
2153
- const list = (s.answers || []).slice().reverse()
2154
- if (!list.length) return ''
2155
- return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
2156
- '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
2157
- '<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
2158
- '<div class="hint">Also in answers.md in this session’s folder.</div>'
2163
+ const body = answeredHtml(s.answers, { clock }, { esc })
2164
+ return body ? '<section class="sanswered" id="answered">' + body + '</section>' : ''
2159
2165
  }
2160
2166
 
2161
2167
  // External agents (v0.5.4): a feed session's page. Everything is the feed's
@@ -2381,6 +2387,10 @@ async function viewSession(id) {
2381
2387
  // 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
2382
2388
  // each session, read before caughtUp folds its card; a new visit starts over.
2383
2389
  const openAway = new Map()
2390
+ // 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
2391
+ // while you were away reads at least "day" depth (ui/away.js), but its label says
2392
+ // how long it has really gone unseen: never more than the session's own age.
2393
+ function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
2384
2394
  function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
2385
2395
  function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
2386
2396
  function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
@@ -2545,9 +2555,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2545
2555
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2546
2556
  '<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>' +
2547
2557
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2548
- '<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>' +
2558
+ '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
2559
+ // 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
2549
2560
  (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>' : '') +
2550
- '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>' + '</div>'
2561
+ '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
2551
2562
  // the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
2552
2563
  noteOpenAway(s)
2553
2564
  const awayMs = openAway.get(s.id)
@@ -2555,7 +2566,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2555
2566
  const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
2556
2567
  // 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
2557
2568
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2558
- const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2569
+ const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2559
2570
  const wtOn = wtUi.sid === s.id
2560
2571
  const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
2561
2572
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
@@ -2571,33 +2582,44 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2571
2582
  const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
2572
2583
  const wr = writingFor(s) // 0.9.5: the one being written, at the top until it lands
2573
2584
  const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
2574
- const cps = s.checkpoints.filter((cp) => !cp.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
2575
- side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
2576
- side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + cpTimeline(cps, newestCp, s.agent ? null : s.id) + '</div>'
2585
+ // 2026-10-29: a wrapper checkpoint ("Working on: <task-notification>…") written beside a real one isn't shown
2586
+ const cps = dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
2587
+ const paths = { roots: rootsOf(s), home: (state.health && state.health.home) || '' }
2588
+ // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2589
+ side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2590
+ 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>'
2591
+ 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 })
2577
2592
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2578
2593
  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>'
2579
2594
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
2580
2595
  if (empties.length) side += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
2581
2596
  side += '</section>'
2597
+ // 2026-10-29 a calmer panel: PRs and To do stay open; the rest is a one-line header with a count, closed
2598
+ // until you open it (kept per device: ui/side-fold.js). Each keeps its id, so its painter still finds it.
2599
+ side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
2600
+ side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
2582
2601
  // 0.9.8: the last tools it ran and its folder's changes; loads when you open it
2583
2602
  side += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
2603
+ const fold = (key, title, count, body) => foldHtml({ key, title, count, body, open: sideFolds.open(key) }, { esc })
2604
+ const fl = s.files || {}, nFiles = (fl.artifacts || []).length + (fl.plans || []).length + (fl.docs || []).length
2605
+ const filesBody = sessionFilesSection(s, { agoFn: ago })
2606
+ side += filesBody ? fold('files', 'Files', nFiles || null, '<section class="sfiles" id="files">' + filesBody + '</section>') : '<section class="sfiles" id="files"></section>'
2607
+ if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
2608
+ state.listsMayEdit = state.mayAnswer !== false
2609
+ const listsBody = sessionListsSection(s)
2610
+ side += listsBody ? fold('lists', 'Lists', (s.lists || []).length || null, '<section class="slists" id="lists">' + listsBody + '</section>') : '<section class="slists" id="lists"></section>'
2611
+ side += fold('park', s.parked ? 'Parked' : 'Park', null, parkRow(s))
2612
+ side += fold('space', 'Space', s.space || null, spacePicker(s, spv, 'session', s.id))
2613
+ side += fold('status', 'Status history', (s.statusChanges || []).length || null, statusStripHtml(s.statusChanges, { label: LABEL, clock }, { esc }))
2614
+ side += fold('answered', 'Answered from the board', (s.answers || []).length || null, answeredList(s))
2584
2615
  side += '<section class="fs-sec fs-details" id="fsDetails">' + detailsHtml( // 0.9.11: the facts, behind Details
2585
2616
  fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Started', clock(s.startedAt)) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + fsOtherRepos(s) + repoTile(s) + branchTileHtml(branch, { esc }) + fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) +
2586
2617
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
2587
2618
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
2588
2619
  (s.headless && !s.terminal && !s.tty ? fact('Terminal', 'None · started from the board', '') : fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' })), { open: dOpen }) + '</section>'
2589
- side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
2590
- side += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
2591
- side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
2592
- if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
2593
- state.listsMayEdit = state.mayAnswer !== false
2594
- side += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
2595
- side += parkRow(s) + spacePicker(s, spv, 'session', s.id)
2596
- if (s.statusChanges && s.statusChanges.length) side += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" data-tip="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
2597
- side += answeredList(s)
2598
2620
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2599
2621
  const label = fsPeekWords(s)
2600
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2622
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, scrolled: fsScrolled === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2601
2623
  }
2602
2624
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2603
2625
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2606,7 +2628,7 @@ function fsStubHtml(s, inner, foot = '') {
2606
2628
  return liveOpenHtml(s, null, '') + '<div class="live-thread fs-none" data-sid="' + esc(s.id) + '"><div class="fs-none-in">' + inner + '</div></div>' + foot + '</section>' // 0.9.17: an ended one keeps its Ended · Resume bar
2607
2629
  }
2608
2630
  function fsPeekWords(s) {
2609
- const n = s.checkpointCount || 0
2631
+ const n = Array.isArray(s.checkpoints) ? dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).length : s.checkpointCount || 0 // 2026-10-29: the junk twin isn't counted
2610
2632
  const w = String(LABEL[s.status] || s.status || 'Session')
2611
2633
  return w.charAt(0).toUpperCase() + w.slice(1) + ' · ' + n + ' checkpoint' + (n === 1 ? '' : 's')
2612
2634
  }
@@ -2981,7 +3003,24 @@ function fsRailGo(go) {
2981
3003
  if (el) el.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
2982
3004
  const d = el && el.querySelector('details:not([open])')
2983
3005
  if (d && go === 'fsDetails') d.open = true
2984
- }
3006
+ const f = el && el.closest('details.fs-fold:not([open])') // 2026-10-29: a folded section opens when the rail goes to it
3007
+ if (f) f.open = true
3008
+ }
3009
+ // 2026-10-29 the panel's folds: open or closed as you leave them, on this device (ui/side-fold.js)
3010
+ document.addEventListener('toggle', (e) => {
3011
+ const d = e.target
3012
+ if (!d || !d.matches || !d.matches('#fsSide details[data-fold]')) return
3013
+ sideFolds.set(d.dataset.fold, d.open)
3014
+ }, true)
3015
+ // The pinned head's title goes to one line once the panel scrolls, so the pinned part stays slim.
3016
+ document.addEventListener('scroll', (e) => {
3017
+ const el = e.target
3018
+ if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3019
+ const side = el.closest('.fs-side')
3020
+ fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3021
+ if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3022
+ }, true)
3023
+
2985
3024
  function toggleFsSheet() {
2986
3025
  const side = document.getElementById('fsSide')
2987
3026
  if (!side) return
@@ -4039,16 +4078,29 @@ async function aroundWire(id) {
4039
4078
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4040
4079
  }
4041
4080
  }
4042
- function cpTimeline(list, newestCp, wtSid = null) {
4081
+ function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4043
4082
  return list.map((cp) =>
4044
- '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"><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>' +
4045
- (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>' +
4046
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
4083
+ '<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4084
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
4047
4085
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4048
- (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
4086
+ filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
4049
4087
  (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
4050
4088
  '</div>').join('')
4051
4089
  }
4090
+ function cpHeadLine(cp) {
4091
+ 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>' +
4092
+ (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4093
+ }
4094
+ // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4095
+ // your note and the files behind More (open or closed as you last left it on this device).
4096
+ function cpLatestHtml(cp, wtSid, paths) {
4097
+ const more = cpBlock(cp, { md: true, part: 'head', paths }) + (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') + filesTouchedHtml(cp.filesTouched, paths || {}, { esc })
4098
+ return '<div class="tl-item tl-latest ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
4099
+ cpBlock(cp, { showWhen: false, md: true, part: 'tail', paths }) +
4100
+ (more ? '<details class="cp-more fs-cp-more" data-fold="cp-more"' + (sideFolds.open('cp-more') ? ' open' : '') + '><summary>More</summary>' + more + '</details>' : '') +
4101
+ (wtSid ? '<button type="button" class="linkish wt-since" data-action="wt-open" data-id="' + esc(wtSid) + '" data-scope="checkpoint">' + icon('route') + 'Walk through since here</button>' : '') +
4102
+ '</div>'
4103
+ }
4052
4104
  // Space of one session (or, on an agent's page, of that agent's feed): its
4053
4105
  // own override, or back to the repo / Mac default (v0.5.8).
4054
4106
  function spacePicker(s, spv, kind, key) {
@@ -4986,6 +5038,8 @@ function anywhereSection(r, adv = []) {
4986
5038
  if (r.enabled) {
4987
5039
  const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
4988
5040
  h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
5041
+ // 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
5042
+ h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
4989
5043
  if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
4990
5044
  // Pairing from a paired phone follows Trust my paired devices (0.6.6).
4991
5045
  const mayPair = v.mayPair != null ? !!v.mayPair : local
@@ -7455,7 +7509,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7455
7509
  clearTimeout(timer); cur = el
7456
7510
  timer = setTimeout(() => {
7457
7511
  if (cur !== el || !el.isConnected) return hide()
7458
- if (el.matches('.wt-st-h h3') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
7512
+ if (el.matches('.wt-st-h h3, .pr-compact .pr-title-btn') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
7459
7513
  tip.textContent = el.dataset.tip
7460
7514
  const r = el.getBoundingClientRect(), w = tip.offsetWidth
7461
7515
  tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
@@ -7463,7 +7517,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7463
7517
  tip.classList.add('on')
7464
7518
  }, delay)
7465
7519
  }
7466
- const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7520
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7467
7521
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7468
7522
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7469
7523
  document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
@@ -8265,6 +8319,18 @@ document.addEventListener('click', async (ev) => {
8265
8319
  refreshHealth().catch(() => {})
8266
8320
  return render()
8267
8321
  }
8322
+ // 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
8323
+ if (action === 'relay-host' || action === 'relay-host-pref') {
8324
+ const machine = btn.dataset.machine || null
8325
+ const prefer = action === 'relay-host-pref'
8326
+ btn.disabled = true
8327
+ let hz
8328
+ try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
8329
+ const now = hz && hz.host && hz.host.machine
8330
+ toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
8331
+ if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
8332
+ return render()
8333
+ }
8268
8334
  if (action === 'relay-user') {
8269
8335
  const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
8270
8336
  await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
@@ -9156,8 +9222,34 @@ async function openToolOutput(sessionId, toolId, label) {
9156
9222
  // daemon serves only the session's folder and Claude's scratch folders
9157
9223
  // (src/file-ref.js). Centered on a desktop, a bottom sheet on a phone.
9158
9224
  const refUi = { sid: null, path: null, line: null, r: null, from: null, seq: 0 }
9225
+ // 2026-10-29 Which session a path in the page belongs to, and whether this board can read its files
9226
+ // (this machine; another over the peer link; the hosted board straight from it): ui/file-reach.js.
9227
+ function refSidOf(el) {
9228
+ if (el.closest('#docViewer')) return refUi.sid
9229
+ const box = el.closest('[data-sid], #live[data-id]')
9230
+ return (box && (box.dataset.sid || box.dataset.id)) || sessionOfHash(location.hash) || liveOnScreen()
9231
+ }
9232
+ function refReachFor(el) {
9233
+ const sid = refSidOf(el)
9234
+ const s = sid && ((state.sessions || []).find((x) => x.id === sid) || (state.session && state.session.id === sid ? state.session : null))
9235
+ if (!s) return { ok: true }
9236
+ return fileReach(s, { relay: window.leftoffRelay && state.relayStatus ? state.relayStatus : null })
9237
+ }
9238
+ {
9239
+ let queued = false
9240
+ const pass = () => { queued = false; try { gateFileRefs(document.body, refReachFor) } catch {} }
9241
+ const mo = new MutationObserver((recs) => {
9242
+ if (queued) return
9243
+ if (!recs.some((r) => r.addedNodes.length)) return
9244
+ queued = true; requestAnimationFrame(pass)
9245
+ })
9246
+ if (document.body) mo.observe(document.body, { childList: true, subtree: true })
9247
+ // a path that can't be read: a tap says why (a phone has no hover tip)
9248
+ document.addEventListener('click', (e) => { const off = e.target.closest && e.target.closest('.file-ref-off'); if (off && matchMedia('(hover: none)').matches) toast(off.dataset.tip, 4000) })
9249
+ }
9159
9250
  function openRefFile(btn, cardId) {
9160
9251
  const inViewer = btn.closest('#docViewer')
9252
+ { const r = inViewer ? { ok: true } : refReachFor(btn); if (!r.ok) { toast(r.tip, 4000); return } } // never a viewer that opens onto "not available"
9161
9253
  let p = btn.dataset.path || ''
9162
9254
  if (inViewer && refUi.r && refUi.r.path && /^\.{1,2}\//.test(p)) p = refAgainst(p, refUi.r.path) // ./ or ../ inside a viewed file: that file's folder (else the session's)
9163
9255
  const sid = (inViewer && refUi.sid) || cardId || sessionOfHash(location.hash) || liveOnScreen()
@@ -0,0 +1,66 @@
1
+ // 2026-10-29 A file path in a message is a link only when the board can read
2
+ // that file (Joel: "in the chat all files are clickable and open in the modal
3
+ // but not available"). The board reads a session's file on the machine that
4
+ // has it: this machine; another machine over the signed, sealed peer link the
5
+ // Files panel already uses (src/engine.js sessionFile -> viaPeer); or, on the
6
+ // hosted board, straight from that machine through the relay
7
+ // (ui/relay-client.js ownerRoute). Nothing new is opened to the network here:
8
+ // this only decides whether to offer the link. Otherwise the path stays plain
9
+ // monospace text and its tip says why. Pure (test/unit/calmside-1029.test.mjs).
10
+
11
+ const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
12
+
13
+ // s: the session (isLocal, machine, peer: { ok, code }, agent)
14
+ // relay: null off the hosted board; else { macs: [{ machine, online, serves, platform }] } from the relay's status
15
+ // -> { ok: true } | { ok: false, why: 'agent' | 'offline' | 'sandbox' | 'unreachable', tip }
16
+ export function fileReach(s, { relay = null } = {}) {
17
+ if (!s) return { ok: true } // no session to ask about: the viewer says what it can
18
+ if (s.agent || /^agent:/.test(String(s.id || ''))) return { ok: false, why: 'agent', tip: 'External agents don’t share files' }
19
+ if (s.isLocal !== false) return { ok: true }
20
+ const m = String(s.machine || 'its machine')
21
+ const x = relay && Array.isArray(relay.macs) ? relay.macs.find((y) => y && y.machine === m) : null
22
+ const peer = s.peer || {}
23
+ if (x && !x.online) return { ok: false, why: 'offline', tip: 'On ' + m + ' (offline). Open the board on ' + m + ' to view' }
24
+ if (peer.ok || peer.code === 'checking') return { ok: true }
25
+ // the hosted board goes straight to the session's machine when that machine can open this browser's key
26
+ if (x && x.online && x.serves !== 0) return { ok: true }
27
+ if (x && x.online) return { ok: false, why: 'sandbox', tip: x.platform && x.platform !== 'darwin' ? 'On ' + m + '. Can’t read files from a sandbox here; open the board on ' + m + ' to view' : 'On ' + m + ', which can’t share its files with this device yet' }
28
+ if (peer.code === 'unreachable' || !x) return { ok: false, why: peer.code === 'unreachable' ? 'offline' : 'unreachable', tip: peer.code === 'unreachable' ? 'On ' + m + ' (offline). Open the board on ' + m + ' to view' : 'On ' + m + ', which this board can’t reach right now' }
29
+ return { ok: false, why: 'unreachable', tip: 'On ' + m + ', which this board can’t reach right now' }
30
+ }
31
+
32
+ // The plain-text form of a path link (the words as shown; the path kept for when it can be read again)
33
+ export function offRefHtml({ path, line = null, shownHtml, tip }, { esc = escD } = {}) {
34
+ return '<span class="file-ref-off" tabindex="0" data-path="' + esc(path) + '"' + (line ? ' data-line="' + esc(String(line)) + '"' : '') + ' data-tip="' + esc(tip) + '" aria-label="' + esc(String(path) + '. ' + tip) + '">' + shownHtml + '</span>'
35
+ }
36
+
37
+ // In the page: every path link a session's machine can't serve becomes plain
38
+ // text, and back to a link once it can. reachFor(el) -> { ok, tip } for the
39
+ // session that element belongs to. Returns how many changed.
40
+ export function gateFileRefs(root, reachFor) {
41
+ if (!root || !root.querySelectorAll) return 0
42
+ let n = 0
43
+ for (const a of root.querySelectorAll('a.file-ref')) {
44
+ const r = reachFor(a)
45
+ if (!r || r.ok) continue
46
+ const sp = a.ownerDocument.createElement('span')
47
+ sp.className = 'file-ref-off'; sp.tabIndex = 0
48
+ sp.setAttribute('data-path', a.getAttribute('data-path') || '')
49
+ if (a.getAttribute('data-line')) sp.setAttribute('data-line', a.getAttribute('data-line'))
50
+ sp.setAttribute('data-tip', r.tip); sp.setAttribute('aria-label', (a.getAttribute('data-path') || '') + '. ' + r.tip)
51
+ while (a.firstChild) sp.appendChild(a.firstChild)
52
+ a.replaceWith(sp); n++
53
+ }
54
+ for (const sp of root.querySelectorAll('span.file-ref-off')) {
55
+ const r = reachFor(sp)
56
+ if (!r || !r.ok) { if (r && r.tip && sp.getAttribute('data-tip') !== r.tip) sp.setAttribute('data-tip', r.tip); continue }
57
+ const a = sp.ownerDocument.createElement('a')
58
+ a.className = 'file-ref'; a.href = '#'
59
+ a.setAttribute('data-action', 'file-ref'); a.setAttribute('data-path', sp.getAttribute('data-path') || '')
60
+ if (sp.getAttribute('data-line')) a.setAttribute('data-line', sp.getAttribute('data-line'))
61
+ a.setAttribute('data-tip', 'Open in Leftoff')
62
+ while (sp.firstChild) a.appendChild(sp.firstChild)
63
+ sp.replaceWith(a); n++
64
+ }
65
+ return n
66
+ }