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

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.18-beta.11",
3
+ "version": "0.9.18-beta.12",
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"
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.11' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.12' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.11",
3
+ "version": "0.9.18-beta.12",
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
@@ -1578,6 +1578,7 @@ a.cpr:hover { border-color: var(--line-strong); }
1578
1578
  .pr-ic[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% - 4px); right: 0; width: max-content; max-width: min(260px, 70vw); padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; text-align: left; white-space: nowrap; letter-spacing: normal; pointer-events: none; z-index: 30; }
1579
1579
  @media (hover: hover) and (pointer: fine) { .pr-ic[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
1580
1580
  .pr-ic[data-tip]:focus-visible::after, .pr-ic.copied[data-tip]::after { display: block; }
1581
+ .pr-ic[aria-expanded="true"][data-tip]::after { display: none !important; } /* 2026-11-06 Open's menu is up: its tip would cover the title */
1581
1582
  @media (hover: hover) and (pointer: fine) { .pr-ic.copied[data-tip]:hover::after { animation: none; } }
1582
1583
  @media (prefers-reduced-motion: reduce) { .pr-ic[data-tip]::after { animation: none !important; } }
1583
1584
  .prlist { margin-bottom: 8px; }
@@ -1641,6 +1642,9 @@ a.cpr:hover { border-color: var(--line-strong); }
1641
1642
  .prrow .pr-line1 { padding-right: 124px; min-height: 24px; }
1642
1643
  .prrow:not(:has(.pr-unlink)) .pr-line1 { padding-right: 80px; }
1643
1644
  .prrow:not(:has(.pr-ic)) .pr-line1 { padding-right: 0; }
1645
+ /* a closed compact row (the session panel) keeps its glyphs in the row, after the one-line title */
1646
+ .pr-compact .prrow:not(.open) .pr-actions { position: static; margin: 0 -2px 0 0; }
1647
+ .pr-compact .prrow:not(.open) { align-items: center; }
1644
1648
  .pr-actions, .prrow.open .pr-actions { position: absolute; top: 0; right: 2px; margin: 0; }
1645
1649
  }
1646
1650
 
@@ -4636,6 +4640,7 @@ html:not(.live-full) .fs-side { display: none; }
4636
4640
  .wt-seg::-webkit-scrollbar { display: none; }
4637
4641
  .wt-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 38px; padding: 1px 14px 0; box-sizing: border-box; border: 0; border-radius: 8px; background: none; color: var(--muted); font: inherit; font-size: 13px; font-weight: 550; line-height: 1; white-space: nowrap; cursor: pointer; }
4638
4642
  .wt-seg button .ic { width: 14px; height: 14px; margin-top: -1px; }
4643
+ .wt-seg button .wt-seg-chev { width: 12px; height: 12px; margin: -1px -4px 0 -2px; opacity: .7; }
4639
4644
  .wt-seg button[aria-pressed="true"] { background: var(--card2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
4640
4645
  .wt-seg button:disabled { opacity: .45; cursor: default; }
4641
4646
  .wt-seg-sm button { height: 34px; padding: 1px 12px 0; }
@@ -5246,6 +5251,14 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
5246
5251
  .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); }
5247
5252
  .pr-compact .pr-meta { margin-top: 2px; font-size: 12px; }
5248
5253
  .pr-compact .pr-actions { margin: 0 -2px 0 0; }
5254
+ /* open: the actions sit at the top right beside the title line, so the details below take the panel's full width */
5255
+ .pr-compact .prrow.open { position: relative; }
5256
+ .pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 48px; }
5257
+ .pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 92px; }
5258
+ .pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 136px; }
5259
+ .pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 180px; }
5260
+ .pr-compact .prrow.open .pr-actions { position: absolute; top: 8px; right: 4px; margin: 0; }
5261
+ @media (pointer: fine) { .pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 40px; } .pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 72px; } .pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 104px; } .pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 136px; } }
5249
5262
  @media (pointer: fine) { .pr-compact .pr-actions .pr-ic { width: 32px; height: 32px; } }
5250
5263
  .sprs .pr-unfold { margin: 0 0 8px; }
5251
5264
  .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; }
package/ui/app.js CHANGED
@@ -43,7 +43,7 @@ import { mdInline, renderSection } from './changelog-md.js'
43
43
  import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
44
44
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
45
45
  import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
46
- import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
46
+ import { STATE_IC as PR_STATE_IC, cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick, WALK_IC as PR_WALK_IC, GH_MARK as PR_GH_IC, safeUrl as prSafeUrl } from './prs.js'
47
47
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
48
48
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
49
49
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
@@ -57,7 +57,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
57
57
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
58
58
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
59
59
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
60
- import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
60
+ import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine, isPrKey, prScopeTip } from './semdiff.js' // 0.9.17 Walk me through it
61
61
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
62
62
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
63
63
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
@@ -1137,8 +1137,27 @@ function card(s) {
1137
1137
  // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1138
1138
  const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1139
1139
  const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1140
- function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1141
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
1140
+ // 2026-11-06 a PR range has no This session · Whole folder: it is the PR's own commits
1141
+ function wtBody(sid, extra = {}) { const sc = wtUi.scope.get(sid) || 'session'; return { scope: sc, ...(fileScope.get() === 'folder' && !isPrKey(sc) ? { files: 'folder' } : {}), ...extra } }
1142
+ function wtKey(sid) { const sc = wtUi.scope.get(sid) || 'session'; return sid + '|' + sc + (fileScope.get() === 'folder' && !isPrKey(sc) ? '|folder' : '') }
1143
+ // the ranges the daemon last listed for a session (any range's answer carries them all)
1144
+ function wtScopesOf(sid) { for (const [k, d] of wtUi.data) if (k.startsWith(sid + '|') && d && Array.isArray(d.scopes)) return d.scopes; return [] }
1145
+ function openWtPrMenu(btn, sid) {
1146
+ const cur = wtUi.scope.get(sid)
1147
+ const prs = wtScopesOf(sid).filter((x) => isPrKey(x.key))
1148
+ lastMenuAnchor = btn
1149
+ openActionMenu(btn, prs.map((x) => ({ label: x.label + (x.title ? ' · ' + x.title : ''), sub: prScopeTip({ repo: x.repo, state: x.state }), icon: PR_STATE_IC[x.state] || icon('git-pull-request'), cls: x.key === cur ? 'on' : '', on: x.key === cur, onLabel: 'shown now',
1150
+ attrs: { 'data-action': 'wt-scope', 'data-id': sid, 'data-scope': x.key } })), { title: 'Walk through one PR' })
1151
+ }
1152
+ // 2026-11-06 a PR row's Open: where to (the session's walkthrough on this PR, or GitHub)
1153
+ function openPrOpenMenu(btn) {
1154
+ const d = btn.dataset, n = '#' + d.num
1155
+ lastMenuAnchor = btn
1156
+ openActionMenu(btn, [
1157
+ { label: 'Walk me through it', sub: 'In Leftoff', icon: PR_WALK_IC, attrs: { 'data-action': 'wt-open', 'data-id': d.id, 'data-scope': d.scope } },
1158
+ ...(d.url ? [{ label: 'Open on GitHub', sub: (d.repo ? d.repo + ' ' : '') + n, icon: PR_GH_IC, attrs: { 'data-action': 'pr-gh', 'data-url': d.url } }] : []),
1159
+ ], { title: 'Open ' + n })
1160
+ }
1142
1161
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1143
1162
  function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
1144
1163
  function wtRestore(sid) {
@@ -1148,6 +1167,8 @@ function wtRestore(sid) {
1148
1167
  }
1149
1168
  async function wtOpen(sid, scope = null) {
1150
1169
  const s = wtSession(sid)
1170
+ // 2026-11-06 from the PRs page, a session this page hasn't loaded: its page opens on the walkthrough (wtRestore)
1171
+ if (!s && scope && sid) { try { sessionStorage.setItem('lo-wt', JSON.stringify({ sid, scope, view: 'story' })) } catch {} wtUi.restored = false; if (wtUi.sid === sid) wtUi.sid = null; location.hash = fullHref(sid); return }
1151
1172
  if (!s || s.agent) return
1152
1173
  wtUi.sid = sid
1153
1174
  if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
@@ -1238,7 +1259,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
1238
1259
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1239
1260
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1240
1261
  async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1241
- const k = wtKey(sid)
1262
+ let k = wtKey(sid)
1242
1263
  const had = wtUi.data.get(k)
1243
1264
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1244
1265
  if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
@@ -1247,6 +1268,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
1247
1268
  try {
1248
1269
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
1249
1270
  if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1271
+ // 2026-11-06 the old 'pr' (remembered from before): the daemon answers for that PR's own range
1272
+ if (r && wtUi.scope.get(sid) === 'pr' && isPrKey(r.scope) && r.scope !== 'pr') { wtUi.scope.set(sid, r.scope); wtRemember(); wtUi.loading.delete(k); k = wtKey(sid); wtUi.loading.add(k) }
1250
1273
  wtUi.data.set(k, r)
1251
1274
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1252
1275
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -9093,10 +9116,13 @@ document.addEventListener('click', async (ev) => {
9093
9116
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
9094
9117
  if (action === 'tab-pin') return togglePin(id)
9095
9118
  // 0.9.17 Walk me through it
9096
- if (action === 'wt-open') return wtOpen(id, btn.dataset.scope || null)
9119
+ if (action === 'pr-open') return openPrOpenMenu(btn)
9120
+ if (action === 'pr-gh') { closeActionMenu(); const u = prSafeUrl(btn.dataset.url); if (u) window.open(u, '_blank', 'noopener,noreferrer'); return }
9121
+ if (action === 'wt-open') { if (btn.closest('.lo-actmenu')) closeActionMenu(); return wtOpen(id, btn.dataset.scope || null) }
9097
9122
  if (action === 'wt-close') return wtClose()
9098
9123
  if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
9099
- if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
9124
+ if (action === 'wt-prs') return openWtPrMenu(btn, btn.dataset.id || wtUi.sid)
9125
+ if (action === 'wt-scope') { if (btn.disabled) return; if (btn.closest('.lo-actmenu')) closeActionMenu(); wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
9100
9126
  if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
9101
9127
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
9102
9128
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
package/ui/prs.js CHANGED
@@ -5,7 +5,7 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
5
5
  import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst, PROJECT_KEY } from './repos.js'
6
6
  import { renderDoc } from './handoffs.js'
7
7
  import { COPY_IC, DONE_IC, COPIED_TIP, COPIED_MS, copyIconClick } from './copy-icon.js'
8
- const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
8
+ export const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
9
9
 
10
10
  export function shortAgo(iso, now = Date.now()) {
11
11
  const t = Date.parse(iso || '')
@@ -93,24 +93,38 @@ 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-11-06 Walk me through this PR: the session's walkthrough opened on this PR's own range (src/walk-pr.js)
97
+ export const WALK_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>'
98
+ export { GH_MARK }
99
+ const OPEN_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
100
+ export const prWalkScope = (p) => (p && p.repo && p.number ? 'pr:' + p.repo + '#' + p.number : null)
101
+ // One Open button: a menu asking where (Walk me through it in Leftoff, or GitHub) when the PR has a walkthrough;
102
+ // else the plain GitHub link. (Joel, 2026-11-06: four glyphs took the title's room.)
103
+ export function prOpenHtml(p, walkId) {
104
+ const url = safeUrl(p.url), sc = walkId && prWalkScope(p), n = '#' + (p.number || '')
105
+ if (sc) return '<button type="button" class="pr-ic pr-open" data-action="pr-open" data-id="' + esc(walkId) + '" data-scope="' + esc(sc) + '" data-url="' + esc(url || '') + '" data-repo="' + esc(p.repo || '') + '" data-num="' + esc(p.number) + '" aria-haspopup="menu" data-tip="Open in Leftoff or on GitHub" aria-label="' + esc('Open ' + n + '…') + '">' + OPEN_IC + '</button>'
106
+ return url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : ''
107
+ }
96
108
  // 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>' : ''
109
+ // (2026-11-06 Open asks Leftoff or GitHub, when the row is a session's)
110
+ export function prGhOnlyHtml(p, { walkId = null } = {}) {
111
+ const open = prOpenHtml(p, walkId)
112
+ return open ? '<div class="pr-actions">' + open + '</div>' : ''
100
113
  }
101
- export function prActionsHtml(p, { unlinkId = null } = {}) {
114
+ export function prActionsHtml(p, { unlinkId = null, walkId = null } = {}) {
102
115
  const url = safeUrl(p.url), n = '#' + (p.number || '')
103
116
  if (!url && !unlinkId) return ''
104
117
  const copyLabel = 'Copy link to ' + n
105
118
  return '<div class="pr-actions">' +
106
119
  (url ? '<button type="button" class="pr-ic pr-copy" data-action="pr-copy" data-copy="' + esc(url) + '" data-tip="' + PR_COPY_TIP + '" data-label="' + esc(copyLabel) + '" aria-label="' + esc(copyLabel) + '">' + COPY_IC + DONE_IC + '</button>' : '') +
107
- (url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : '') +
120
+ prOpenHtml(p, walkId) +
108
121
  (unlinkId ? '<button type="button" class="pr-ic pr-unlink" data-action="pr-unlink" data-id="' + esc(unlinkId) + '" data-url="' + esc(p.url) + '" data-tip="Unlink from this session" aria-label="Unlink ' + esc(n) + ' from this session">' + UNLINK + '</button>' : '') +
109
122
  '</div>'
110
123
  }
111
124
 
112
125
  // The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
113
126
  export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
127
+ const walkId = s && !s.agent ? s.id : null
114
128
  // 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
115
129
  const more = (s.prsMore || []).filter((p) => p && p.number)
116
130
  const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
@@ -122,11 +136,13 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
122
136
  // the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
123
137
  h += '<div class="prlist pr-compact">' + list.map((p) => {
124
138
  const isOpen = !!(open && open.has && open.has(p.url))
125
- return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
139
+ // how many glyphs sit at the right (the title line leaves them room when open)
140
+ const acts = isOpen ? (safeUrl(p.url) ? 1 : 0) + (safeUrl(p.url) || (prWalkScope(p) && walkId) ? 1 : 0) + (mayEdit ? 1 : 0) : 0
141
+ return '<article class="prrow slim' + (isOpen ? ' open pr-acts-' + acts : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
126
142
  '<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
127
143
  '<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>'
144
+ (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) ? ' · ' + checksHtml(p.checks) : '') + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
145
+ (isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null, walkId }) : prGhOnlyHtml(p, { walkId })) + '</article>'
130
146
  }).join('') + '</div>'
131
147
  }
132
148
  // several refs that couldn't be checked fold into one "+4 not checked" row
@@ -198,12 +214,14 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
198
214
  : [p.repo, p.branch, when].filter(Boolean)
199
215
  const why = whyText(r)
200
216
  const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
217
+ // 2026-11-06 Walk me through this PR: in its newest session's walkthrough (never a scripted run's)
218
+ const walkTo = !team ? (r.sessions || []).find((x) => x && x.id && !x.nonInteractive && x.archivedReason !== 'non_interactive' && !x.agent) : null
201
219
  return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
202
220
  '<div class="pr-line1">' + line1 + '</div>' +
203
221
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
204
222
  '<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
205
223
  sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
206
- prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null }) + '</article>'
224
+ prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null, walkId: walkTo ? walkTo.id : null }) + '</article>'
207
225
  }
208
226
 
209
227
  // ---------------------------------------------------------------- 0.8.5: details on tap
package/ui/semdiff.js CHANGED
@@ -371,11 +371,25 @@ function fileMapHtml(data, { esc, icon, cur }) {
371
371
  return h + '<div class="wt-fm-k"><span class="kbd">j</span><span class="kbd">k</span> next step</div></nav>'
372
372
  }
373
373
 
374
+ // 2026-11-06 every PR the session has is its own range ('pr:<owner/repo>#<n>'): up to PR_CHIPS of them are
375
+ // chips; more fold into one "PRs" chip that opens a menu (the one picked shows its number)
376
+ export const PR_CHIPS = 2
377
+ export const isPrKey = (k) => k === 'pr' || /^pr:/.test(String(k || ''))
378
+ const PR_WORD = { merged: 'merged', closed: 'closed', draft: 'draft' }
379
+ export function prScopeTip(x) { return [x.title, x.repo, x.state ? (x.state === 'open' ? 'Open' : x.state[0].toUpperCase() + x.state.slice(1)) : ''].filter(Boolean).join(' · ') }
374
380
  function scopesHtml(data, scope, { esc, icon, sid }) {
375
381
  const sc = (data && data.scopes) || [{ key: 'checkpoint', label: SCOPE_WORDS.checkpoint, available: false }, { key: 'session', label: SCOPE_WORDS.session, available: true }]
376
- return '<div class="wt-seg" role="group" aria-label="What to walk through">' + sc.map((x) =>
377
- '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : '') + '>' +
378
- (x.key === 'pr' ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + '</span></button>').join('') + '</div>'
382
+ const prs = sc.filter((x) => isPrKey(x.key)), rest = sc.filter((x) => !isPrKey(x.key))
383
+ const chip = (x) => '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : isPrKey(x.key) && prScopeTip(x) ? ' data-tip="' + esc(prScopeTip(x)) + '"' : '') + '>' +
384
+ (isPrKey(x.key) ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + (PR_WORD[x.state] ? ' · ' + PR_WORD[x.state] : '') + '</span></button>'
385
+ let h = rest.map(chip).join('')
386
+ if (prs.length <= PR_CHIPS) h += prs.map(chip).join('')
387
+ else {
388
+ const cur = prs.find((x) => x.key === scope)
389
+ h += '<button type="button" data-action="wt-prs" data-id="' + esc(sid) + '" aria-pressed="' + (cur ? 'true' : 'false') + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc(cur ? cur.label + ', pick another of ' + prs.length + ' PRs' : 'Pick one of ' + prs.length + ' PRs') + '"' + (cur && prScopeTip(cur) ? ' data-tip="' + esc(prScopeTip(cur)) + '"' : '') + '>' +
390
+ icon('git-pull-request') + '<span>' + esc(cur ? cur.label + (PR_WORD[cur.state] ? ' · ' + PR_WORD[cur.state] : '') : 'PRs · ' + prs.length) + '</span>' + icon('chevron-down', 'ic wt-seg-chev') + '</button>'
391
+ }
392
+ return '<div class="wt-seg" role="group" aria-label="What to walk through">' + h + '</div>'
379
393
  }
380
394
 
381
395
  // 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
@@ -464,7 +478,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
464
478
  if (ui.loading) h += '<p class="wt-sum wt-loading" role="status"><span class="wt-spin" aria-hidden="true"></span><span>Reading the change' + (data && data.status === 'ok' ? '' : '…') + '</span><button type="button" class="linkish wt-stop" data-action="wt-stop" data-id="' + esc(sid) + '">Stop</button></p>'
465
479
  if (ui.error) h += '<p class="wt-note" role="alert">' + icon('circle-alert') + '<span>' + esc(ui.error) + '</span></p>'
466
480
  if (data && !ui.loading && data.status !== 'ok' && data.message) {
467
- const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope === 'pr' ? 'No changes in this PR yet.' : data.message
481
+ const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && isPrKey(scope) ? 'No changes in this PR yet.' : data.message
468
482
  h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
469
483
  h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
470
484
  // a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
@@ -473,7 +487,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
473
487
  if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
474
488
  }
475
489
  // 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
476
- if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
490
+ if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (isPrKey(ui.scope) ? 'No walkthrough of this PR yet.' : data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
477
491
  const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
478
492
  if (ok && view === 'story' && !deck) {
479
493
  const steps = data.steps || []
@@ -562,7 +576,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
562
576
  let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
563
577
  // the overview
564
578
  const st = data.stats || {}
565
- const scope = SCOPE_WORDS[ui.scope] || ''
579
+ const scope = SCOPE_WORDS[ui.scope] || (((data.scopes || []).find((x) => x.key === ui.scope) || {}).label) || ''
566
580
  const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
567
581
  let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
568
582
  o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'