@leftoff/cli 0.9.17-beta.8 → 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.8",
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.8' // 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.8",
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
@@ -5132,3 +5132,80 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
5132
5132
  .relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
5133
5133
  .relay-use { min-height: 44px; margin-left: auto; }
5134
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
@@ -52,6 +52,8 @@ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
52
52
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
53
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'
54
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
55
57
  import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName } from './semdiff.js' // 0.9.17 Walk me through it
56
58
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
57
59
  import { lastReply, ghostShows } from './ghost.js'
@@ -335,6 +337,8 @@ const ORDER = [
335
337
  ['stale', 'Stale'],
336
338
  ['ended', 'Ended today'],
337
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
338
342
  const LABEL = { needs_input: 'needs you', active: 'active', background: 'tasks running', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
339
343
 
340
344
  // ------------------------------------------------------------ helpers
@@ -468,14 +472,14 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
468
472
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
469
473
  const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
470
474
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
471
- function cpExtras(cp) {
475
+ function cpExtras(cp, paths = null) {
472
476
  let h = ''
473
477
  for (const [k, title] of EXTRA_TITLES) {
474
478
  const v = cp[k]
475
479
  if (!v || (Array.isArray(v) && !v.length)) continue
476
480
  h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
477
481
  if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
478
- 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>'
479
483
  else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
480
484
  h += '</div>'
481
485
  }
@@ -486,7 +490,9 @@ function cpExtras(cp) {
486
490
  // md (0.9.5): the session page's full view renders a markdown checkpoint (a
487
491
  // model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
488
492
  // rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
489
- 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 } = {}) {
490
496
  if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
491
497
  cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
492
498
  const you = /^YOU:/i.test(cp.next || '')
@@ -500,6 +506,8 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
500
506
  }
501
507
  // 0.9.15: what the session is for and how far along, above its latest step
502
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>' : '')
503
511
  return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
504
512
  (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
505
513
  (rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
@@ -507,7 +515,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
507
515
  (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
508
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>' : '') +
509
517
  '</dl>' +
510
- (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>' : '')
511
519
  }
512
520
 
513
521
  // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
@@ -2068,7 +2076,7 @@ async function viewWaiting() {
2068
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>' +
2069
2077
  (r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
2070
2078
  '<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
2071
- (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>'
2072
2080
  : '<div class="nocp">Nothing yet since the daemon started.</div>') +
2073
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>'
2074
2082
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
@@ -2150,13 +2158,10 @@ document.addEventListener('keydown', (ev) => {
2150
2158
  const b = document.querySelector('[data-action="agent-add"]')
2151
2159
  if (b && !b.disabled) b.click()
2152
2160
  })
2161
+ // 2026-10-29: the newest three, then Show all N; where each came from in true words (ui/side-fold.js)
2153
2162
  function answeredList(s) {
2154
- const list = (s.answers || []).slice().reverse()
2155
- if (!list.length) return ''
2156
- return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
2157
- '<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>' : '') +
2158
- '<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
2159
- '<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>' : ''
2160
2165
  }
2161
2166
 
2162
2167
  // External agents (v0.5.4): a feed session's page. Everything is the feed's
@@ -2550,9 +2555,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2550
2555
  const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2551
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>' +
2552
2557
  title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2553
- '<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
2554
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>' : '') +
2555
- '<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>'
2556
2562
  // the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
2557
2563
  noteOpenAway(s)
2558
2564
  const awayMs = openAway.get(s.id)
@@ -2576,33 +2582,44 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2576
2582
  const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
2577
2583
  const wr = writingFor(s) // 0.9.5: the one being written, at the top until it lands
2578
2584
  const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
2579
- const cps = s.checkpoints.filter((cp) => !cp.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
2580
- side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
2581
- 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 })
2582
2592
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2583
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>'
2584
2594
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
2585
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>'
2586
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
2587
2601
  // 0.9.8: the last tools it ran and its folder's changes; loads when you open it
2588
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))
2589
2615
  side += '<section class="fs-sec fs-details" id="fsDetails">' + detailsHtml( // 0.9.11: the facts, behind Details
2590
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)) +
2591
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' }) +
2592
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' }) +
2593
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>'
2594
- side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
2595
- side += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
2596
- side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
2597
- if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
2598
- state.listsMayEdit = state.mayAnswer !== false
2599
- side += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
2600
- side += parkRow(s) + spacePicker(s, spv, 'session', s.id)
2601
- 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>'
2602
- side += answeredList(s)
2603
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 ⋯
2604
2621
  const label = fsPeekWords(s)
2605
- 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, 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 })
2606
2623
  }
2607
2624
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2608
2625
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2611,7 +2628,7 @@ function fsStubHtml(s, inner, foot = '') {
2611
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
2612
2629
  }
2613
2630
  function fsPeekWords(s) {
2614
- 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
2615
2632
  const w = String(LABEL[s.status] || s.status || 'Session')
2616
2633
  return w.charAt(0).toUpperCase() + w.slice(1) + ' · ' + n + ' checkpoint' + (n === 1 ? '' : 's')
2617
2634
  }
@@ -2986,7 +3003,24 @@ function fsRailGo(go) {
2986
3003
  if (el) el.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
2987
3004
  const d = el && el.querySelector('details:not([open])')
2988
3005
  if (d && go === 'fsDetails') d.open = true
2989
- }
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
+
2990
3024
  function toggleFsSheet() {
2991
3025
  const side = document.getElementById('fsSide')
2992
3026
  if (!side) return
@@ -4044,16 +4078,29 @@ async function aroundWire(id) {
4044
4078
  if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
4045
4079
  }
4046
4080
  }
4047
- function cpTimeline(list, newestCp, wtSid = null) {
4081
+ function cpTimeline(list, newestCp, wtSid = null, paths = null) {
4048
4082
  return list.map((cp) =>
4049
- '<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>' +
4050
- (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>' +
4051
- 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 }) +
4052
4085
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
4053
- (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
4054
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
4055
4088
  '</div>').join('')
4056
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
+ }
4057
4104
  // Space of one session (or, on an agent's page, of that agent's feed): its
4058
4105
  // own override, or back to the repo / Mac default (v0.5.8).
4059
4106
  function spacePicker(s, spv, kind, key) {
@@ -7462,7 +7509,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7462
7509
  clearTimeout(timer); cur = el
7463
7510
  timer = setTimeout(() => {
7464
7511
  if (cur !== el || !el.isConnected) return hide()
7465
- 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
7466
7513
  tip.textContent = el.dataset.tip
7467
7514
  const r = el.getBoundingClientRect(), w = tip.offsetWidth
7468
7515
  tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
@@ -7470,7 +7517,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7470
7517
  tip.classList.add('on')
7471
7518
  }, delay)
7472
7519
  }
7473
- 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
7474
7521
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7475
7522
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7476
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() })
@@ -9175,8 +9222,34 @@ async function openToolOutput(sessionId, toolId, label) {
9175
9222
  // daemon serves only the session's folder and Claude's scratch folders
9176
9223
  // (src/file-ref.js). Centered on a desktop, a bottom sheet on a phone.
9177
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
+ }
9178
9250
  function openRefFile(btn, cardId) {
9179
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"
9180
9253
  let p = btn.dataset.path || ''
9181
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)
9182
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
+ }
package/ui/fs-side.js CHANGED
@@ -71,12 +71,12 @@ export function railHtml({ cpCount = 0, cu = true, away = '' } = {}, { esc, icon
71
71
  // The whole panel. side/foot: the panel's markup (built by app.js from the
72
72
  // session); label: the peek's one line; collapsed: the rail instead (desktop);
73
73
  // sheet: the phone's sheet is raised.
74
- export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
74
+ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, scrolled = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
75
75
  const peek = '<div class="fs-peek">' +
76
76
  '<button type="button" class="fs-peek-b" data-action="fs-sheet" aria-expanded="' + (sheet ? 'true' : 'false') + '" aria-controls="fsSide"><span class="fs-grip" aria-hidden="true"></span><span class="fs-peek-t">' + esc(label) + '</span>' + icon(sheet ? 'chevron-down' : 'chevron-up') + '</button>' +
77
77
  (cu ? '<button type="button" class="btn fs-peek-cu' + (away ? ' primary' : '') + '" data-action="scu-open" data-id="' + esc(id) + '"' + (away ? ' aria-label="' + esc(away + ' · Catch me up') + '"' : '') + '>' + icon('coffee') + 'Catch me up</button>' : '') +
78
78
  // 0.9.17 Walk me through it, beside Catch me up in the peek (its eye count when there's something to look at)
79
79
  (wt ? '<button type="button" class="btn fs-peek-wt" data-action="wt-open" data-id="' + esc(id) + '" aria-label="' + esc('Walk me through it' + (look ? ', ' + look + ' to look closely at' : '')) + '" data-tip="Walk me through the code changes">' + icon('git-compare') + (look ? '<span class="fs-peek-n" aria-hidden="true">' + esc(String(look)) + '</span>' : '') + '</button>' : '') + '</div>'
80
- return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
80
+ return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
81
81
  resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
82
82
  }
package/ui/live-view.js CHANGED
@@ -168,7 +168,7 @@ export function senderLabel(m) {
168
168
  if (!device && by.transport === 'local') device = 'this Mac'
169
169
  if (!device && m.where) { // older entries: just the where line
170
170
  const w = String(m.where)
171
- device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
171
+ device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || (w.match(/^from (.+?) · (?:via relay|direct)\b/) || [])[1] || null // 2026-10-29: "from iPhone · via relay"
172
172
  }
173
173
  let label = 'You' + (device ? ' · from ' + device : '')
174
174
  const path = PATHS[by.transport] || null
package/ui/prs.js CHANGED
@@ -93,6 +93,11 @@ export async function prCopyClick(btn, env = {}) {
93
93
  export function prMoreHtml(p, open = false) {
94
94
  return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
95
95
  }
96
+ // 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
97
+ export function prGhOnlyHtml(p) {
98
+ const url = safeUrl(p.url)
99
+ return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
100
+ }
96
101
  export function prActionsHtml(p, { unlinkId = null } = {}) {
97
102
  const url = safeUrl(p.url), n = '#' + (p.number || '')
98
103
  if (!url && !unlinkId) return ''
@@ -113,21 +118,25 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
113
118
  const n = list.length + unchecked.length
114
119
  let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
115
120
  if (list.length) {
116
- h += '<div class="prlist">' + list.map((p) => {
117
- // 0.9.8: tap the title (or More…) for the same details as the PRs page, in place; GitHub ↗ still goes out
121
+ // 2026-10-29 one compact row each: the state (shape + word), #, the title on one line; tap the title for
122
+ // the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
123
+ h += '<div class="prlist pr-compact">' + list.map((p) => {
118
124
  const isOpen = !!(open && open.has && open.has(p.url))
119
125
  return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
120
- '<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
121
- '<div class="pr-title"><button type="button" class="linkish pr-title-btn" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></div>' +
122
- '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + prMoreHtml(p, isOpen) + '</div>' +
123
- (isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
124
- prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
126
+ '<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
127
+ '<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
128
+ (isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
129
+ (isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
125
130
  }).join('') + '</div>'
126
131
  }
127
- if (unchecked.length) h += '<div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div>'
132
+ // several refs that couldn't be checked fold into one "+4 not checked" row
133
+ if (unchecked.length === 1) h += '<div class="pr-unlist">' + uncheckedHtml(unchecked[0]) + '</div>'
134
+ else if (unchecked.length > 1) h += '<details class="pr-unfold"><summary class="pr-unsum">' + STATE_IC.unchecked + '<span>+' + unchecked.length + ' not checked</span></summary><div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div></details>'
128
135
  if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
129
136
  (mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
130
- if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
137
+ const form = '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
138
+ // 2026-10-29: with PRs already linked, the box waits behind one quiet "Link another PR…"
139
+ if (mayEdit) h += n ? '<details class="pr-linkd"><summary class="linkish">Link another PR…</summary>' + form + '</details>' : form
131
140
  return h
132
141
  }
133
142
  // 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).