@leftoff/cli 0.9.18-beta.10 → 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.
package/ui/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
- import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
5
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
6
6
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
7
7
  import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
8
8
  import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
@@ -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'
@@ -54,14 +54,15 @@ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason,
54
54
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
55
55
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
56
56
  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'
57
- import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
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
64
64
  import { lastReply, ghostShows } from './ghost.js'
65
+ import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
65
66
  import { digestHeadline, todayHidden, hideToday, todayCardHtml } from './today.js' // 0.9.16 the board's Today card // 0.9.16 the suggested next message // 0.9.16 the session panel beside full screen
66
67
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
67
68
  import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
@@ -1136,8 +1137,27 @@ function card(s) {
1136
1137
  // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1137
1138
  const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1138
1139
  const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
1139
- function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
1140
- function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
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
+ }
1141
1161
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1142
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 {} }
1143
1163
  function wtRestore(sid) {
@@ -1147,6 +1167,8 @@ function wtRestore(sid) {
1147
1167
  }
1148
1168
  async function wtOpen(sid, scope = null) {
1149
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 }
1150
1172
  if (!s || s.agent) return
1151
1173
  wtUi.sid = sid
1152
1174
  if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
@@ -1237,7 +1259,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
1237
1259
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1238
1260
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1239
1261
  async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1240
- const k = wtKey(sid)
1262
+ let k = wtKey(sid)
1241
1263
  const had = wtUi.data.get(k)
1242
1264
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1243
1265
  if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
@@ -1246,6 +1268,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
1246
1268
  try {
1247
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
1248
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) }
1249
1273
  wtUi.data.set(k, r)
1250
1274
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1251
1275
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -3406,10 +3430,64 @@ document.addEventListener('scroll', (e) => {
3406
3430
  const el = e.target
3407
3431
  if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3408
3432
  const side = el.closest('.fs-side')
3433
+ // 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
3434
+ // content under your finger by the head's change in height, which flipped it back (the sheet jumped)
3435
+ if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
3409
3436
  fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3410
3437
  if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3411
3438
  }, true)
3412
3439
 
3440
+ // 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
3441
+ // raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
3442
+ // it. Anything else is the content's own native scroll, untouched.
3443
+ const sheetMq = window.matchMedia('(max-width: 600px)')
3444
+ {
3445
+ let d = null
3446
+ const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
3447
+ document.addEventListener('touchstart', (e) => {
3448
+ d = null
3449
+ if (!sheetMq.matches || e.touches.length !== 1) return
3450
+ const side = e.target.closest && e.target.closest('#fsSide')
3451
+ if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
3452
+ const fromPeek = !!e.target.closest('.fs-peek')
3453
+ const inner = side.querySelector('.fs-side-in')
3454
+ if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
3455
+ // a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
3456
+ for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
3457
+ const p = e.touches[0]
3458
+ d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
3459
+ }, { passive: true })
3460
+ document.addEventListener('touchmove', (e) => {
3461
+ if (!d || e.touches.length !== 1) return
3462
+ const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
3463
+ if (!d.mode) {
3464
+ d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
3465
+ if (!d.mode) return
3466
+ if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
3467
+ d.travel = travel(d.side)
3468
+ d.side.classList.add('dragging')
3469
+ }
3470
+ if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
3471
+ const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
3472
+ d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
3473
+ }, { passive: false })
3474
+ const end = (e, cancel) => {
3475
+ const g = d
3476
+ d = null
3477
+ if (!g || g.mode !== 'drag') return
3478
+ g.side.classList.remove('dragging')
3479
+ g.side.style.transform = ''
3480
+ const p = e.changedTouches && e.changedTouches[0]
3481
+ if (cancel || !p) return
3482
+ if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
3483
+ toggleFsSheet()
3484
+ sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
3485
+ }
3486
+ }
3487
+ document.addEventListener('touchend', (e) => end(e, false), { passive: true })
3488
+ document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
3489
+ }
3490
+ const sheetUi = { ignoreClick: 0 }
3413
3491
  function toggleFsSheet() {
3414
3492
  const side = document.getElementById('fsSide')
3415
3493
  if (!side) return
@@ -3464,10 +3542,19 @@ function setFullClass(on) {
3464
3542
  // and the composer stays just above the keyboard. When the keyboard closes the
3465
3543
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3466
3544
  // its pan behind after a send, which put full screen ~300px down the screen).
3467
- const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
3545
+ const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
3546
+ // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3547
+ function fitSheetAside() {
3548
+ const html = document.documentElement, a = document.activeElement
3549
+ const typing = isTypingTarget(a)
3550
+ const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
3551
+ const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
3552
+ html.classList.toggle('lf-typing', aside)
3553
+ }
3468
3554
  function fitFullViewport() {
3469
3555
  const vv = window.visualViewport
3470
3556
  const html = document.documentElement, st = html.style
3557
+ fitSheetAside()
3471
3558
  if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3472
3559
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3473
3560
  const strip = document.getElementById('tabStrip')
@@ -3520,6 +3607,8 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
3520
3607
  document.addEventListener('focusin', (e) => {
3521
3608
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3522
3609
  if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3610
+ clearTimeout(lfKb.back); lfKb.blurAt = 0
3611
+ fitSheetAside() // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
3523
3612
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3524
3613
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3525
3614
  settleWhileTyping(0)
@@ -3529,6 +3618,11 @@ document.addEventListener('focusin', (e) => {
3529
3618
  document.addEventListener('focusout', (e) => {
3530
3619
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3531
3620
  clearTimeout(lfKb.settle)
3621
+ if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
3622
+ lfKb.blurAt = Date.now()
3623
+ clearTimeout(lfKb.back)
3624
+ lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
3625
+ }
3532
3626
  let i = 0
3533
3627
  const step = () => {
3534
3628
  fitFullViewport()
@@ -4011,6 +4105,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
4011
4105
  function liveAfterRender(sid) {
4012
4106
  for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
4013
4107
  bangAfterRender()
4108
+ slashAfterRender()
4014
4109
  const r = liveKeeper.after($app)
4015
4110
  hydrateAttThumbs(); barWidths($app)
4016
4111
  fullAfterRender(sid)
@@ -4434,6 +4529,91 @@ function bangAfterRender() {
4434
4529
  if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
4435
4530
  }
4436
4531
  }
4532
+ // ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
4533
+ // The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
4534
+ const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
4535
+ function slashFor(ta) {
4536
+ const box = ta && ta.closest('.live-box .composer')
4537
+ if (!box || ta.disabled) return null
4538
+ if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
4539
+ return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
4540
+ }
4541
+ function paintSlash(ta, { keep = false } = {}) {
4542
+ const box = ta && ta.closest('.composer')
4543
+ if (!box) return null
4544
+ const menu = slashFor(ta)
4545
+ let el = box.querySelector('.slash-menu')
4546
+ if (!menu) {
4547
+ if (el) el.remove()
4548
+ box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
4549
+ slashUi.active = -1
4550
+ return null
4551
+ }
4552
+ if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
4553
+ const id = 'slashMenu-' + box.dataset.sid
4554
+ const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
4555
+ if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
4556
+ box.classList.add('has-slash')
4557
+ ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
4558
+ if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
4559
+ const on = box.querySelector('.slash-opt[aria-selected="true"]')
4560
+ if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
4561
+ return menu
4562
+ }
4563
+ function pickSlash(ta, value) {
4564
+ ta.value = value
4565
+ try { ta.setSelectionRange(value.length, value.length) } catch {}
4566
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
4567
+ ta.focus({ preventScroll: true })
4568
+ }
4569
+ document.addEventListener('input', (ev) => {
4570
+ const ta = ev.target
4571
+ if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
4572
+ if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
4573
+ paintSlash(ta)
4574
+ })
4575
+ // before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
4576
+ window.addEventListener('keydown', (ev) => {
4577
+ const ta = ev.target
4578
+ if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
4579
+ const box = ta.closest('.live-box .composer')
4580
+ if (!box || !box.querySelector('.slash-menu')) return
4581
+ const k = slashKey(ev)
4582
+ if (!k) return
4583
+ const menu = slashFor(ta)
4584
+ if (!menu) return
4585
+ ev.preventDefault(); ev.stopPropagation()
4586
+ if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
4587
+ if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
4588
+ const it = menu.items[slashUi.active]
4589
+ if (it && !it.off) pickSlash(ta, it.value)
4590
+ }, true)
4591
+ // a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
4592
+ // for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
4593
+ function pickSlashOpt(o) {
4594
+ if (!o || o.getAttribute('aria-disabled') === 'true') return false
4595
+ const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
4596
+ if (ta) pickSlash(ta, o.dataset.value)
4597
+ return !!ta
4598
+ }
4599
+ let slashTap = null
4600
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
4601
+ document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
4602
+ document.addEventListener('touchstart', (ev) => {
4603
+ const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
4604
+ slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
4605
+ }, { passive: true })
4606
+ document.addEventListener('touchend', (ev) => {
4607
+ const t = slashTap
4608
+ slashTap = null
4609
+ const p = ev.changedTouches && ev.changedTouches[0]
4610
+ if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
4611
+ if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
4612
+ }, { passive: false })
4613
+ function slashAfterRender() {
4614
+ const ta = document.activeElement
4615
+ if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
4616
+ }
4437
4617
  async function liveSendClick(btn) {
4438
4618
  const box = btn.closest('.live-box')
4439
4619
  const ta = box && box.querySelector('.live-text')
@@ -8932,14 +9112,17 @@ document.addEventListener('click', async (ev) => {
8932
9112
  // 0.9.16 the session panel (ui/fs-side.js)
8933
9113
  if (action === 'fs-side') return toggleFsSide()
8934
9114
  if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
8935
- if (action === 'fs-sheet') return toggleFsSheet()
9115
+ if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
8936
9116
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
8937
9117
  if (action === 'tab-pin') return togglePin(id)
8938
9118
  // 0.9.17 Walk me through it
8939
- 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) }
8940
9122
  if (action === 'wt-close') return wtClose()
8941
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 }
8942
- if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
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) }
8943
9126
  if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
8944
9127
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
8945
9128
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
package/ui/chip-fade.js CHANGED
@@ -18,7 +18,7 @@ export function paintFade(track) {
18
18
  return s
19
19
  }
20
20
 
21
- const SEL = '.repos .track'
21
+ const SEL = '.repos .track, #tabStrip .tab-scroll' // 0.9.18: the tab strip fades where tabs run off too
22
22
  // Keeps every chip row's fade right: on first paint (any re-render adds new
23
23
  // tracks), on its own scroll, and when its width or its chips' widths change.
24
24
  export function watchChipFades(doc = document) {
package/ui/decisions.js CHANGED
@@ -10,6 +10,15 @@ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', corr
10
10
  export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
11
  export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
12
 
13
+ // 2026-11-05: a status Leftoff set on its own (statusAuto), in words beside the source, and why in the tip.
14
+ export const AUTO_WORDS = { asked: 'automatically', plan: 'automatically', reversed: 'you changed it later' }
15
+ export const AUTO_TIPS = {
16
+ asked: 'Accepted on its own: you picked this answer in the session. Reject keeps it out of new sessions.',
17
+ plan: 'Accepted on its own: you approved this plan in the session. Reject keeps it out of new sessions.',
18
+ reversed: 'Rejected on its own: later in the session you answered the same question differently.',
19
+ }
20
+ const autoOf = (d) => (d && ((d.status === 'accepted' && (d.statusAuto === 'asked' || d.statusAuto === 'plan')) || (d.status === 'rejected' && d.statusAuto === 'reversed')) ? d.statusAuto : null)
21
+ const autoBit = (d, esc) => { const a = autoOf(d); return a ? '<span class="dec-auto" data-tip="' + esc(AUTO_TIPS[a]) + '">' + esc(AUTO_WORDS[a]) + '</span>' : '' }
13
22
  const normD = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
14
23
  const byNewest = (a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
15
24
 
@@ -61,11 +70,12 @@ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null
61
70
  const rp = (hit && (hit.repo || repoId)) || ''
62
71
  const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
63
72
  const acts = !readOnly && hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
64
- '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
73
+ '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
74
+ : !readOnly && hit && may && autoOf(hit) && hit.status === 'accepted' ? '<div class="dec-acts dec-acts-panel dec-acts-quiet">' + btn('reject', 'Reject', '') + '</div>' : ''
65
75
  const why = (hit && hit.why) || d.why, alts = (hit && hit.alternatives && hit.alternatives.length ? hit.alternatives : d.alternatives) || []
66
76
  // a quiet line first (status · id · where it came from), then the decision in words, then why / over
67
77
  const src = '<span class="dec-src">' + esc(SOURCE_WORDS[(hit && hit.source) || d.source] || SOURCE_WORDS.agent) + '</span>'
68
- const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
78
+ const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src, autoBit(hit, esc)].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
69
79
  return '<li class="dec-row' + (hit ? ' dec-row-' + esc(hit.status) : '') + '"' + (hit && !readOnly ? ' id="decp-' + esc(hit.id) + '"' : '') + '><div class="dec-row-h">' + head + '</div>' +
70
80
  '<div class="dec-text">' + esc(hit && (hit.edited || d.entry) ? hit.text : d.text) + '</div>' +
71
81
  (why ? '<div class="dec-why">' + esc(why) + '</div>' : '') +
@@ -155,13 +165,13 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
155
165
  const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
156
166
  const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
157
167
  const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
158
- : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
168
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : (autoOf(d) && d.status === 'accepted' ? btn('reject', 'Reject') : '') + editBtn('linkish')) + link + '</div>'
159
169
  return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
160
170
  '<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
161
171
  (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
162
172
  (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
163
173
  chainBit(d, byId, esc) + sug +
164
- '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
174
+ '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (autoBit(d, esc) ? ' · ' + autoBit(d, esc) : '') + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
165
175
  }
166
176
 
167
177
  // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
@@ -195,7 +205,7 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
195
205
  (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
196
206
  if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
197
207
  if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
198
- if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
208
+ if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Questions you answered and plans you approved are accepted on their own. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
199
209
  const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
200
210
  '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
201
211
  h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
package/ui/fs-side.js CHANGED
@@ -80,3 +80,32 @@ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cp
80
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
  }
83
+
84
+ // 2026-11-06 The phone's sheet follows a finger: drag its peek up to raise it, and drag the raised
85
+ // sheet down to lower it, from the peek or from its content when that's scrolled to the very top.
86
+ // Everywhere else a finger scrolls the sheet's content natively, both ways (a drag never starts
87
+ // once the content has room above it, nor when the finger first heads the way that scrolls it).
88
+ export const SHEET_DRAG_SLOP = 8 // px a finger moves before it's a drag or a scroll
89
+ export const SHEET_DRAG_FLIP = 64 // px of drag that raises or lowers the sheet on letting go
90
+ // up: the sheet is raised; fromPeek: the finger started on the peek; scrollTop: the content's at the touch's start;
91
+ // dy, dx: the finger's travel so far (down and right positive) -> 'drag' | 'scroll' | null (not decided yet)
92
+ export function sheetDragStart({ up = false, fromPeek = false, scrollTop = 0, dy = 0, dx = 0 } = {}) {
93
+ const ay = Math.abs(dy), ax = Math.abs(dx)
94
+ if (ay < SHEET_DRAG_SLOP && ax < SHEET_DRAG_SLOP) return null
95
+ if (ax > ay) return 'scroll' // sideways: a table or code block, never the sheet
96
+ if (!up) return fromPeek && dy < 0 ? 'drag' : 'scroll' // lowered: only the peek, only upwards
97
+ if (dy <= 0) return 'scroll' // raised, finger heading up: the content scrolls down
98
+ return fromPeek || (Number(scrollTop) || 0) <= 0 ? 'drag' : 'scroll'
99
+ }
100
+ // How far the sheet sits from where it rests while dragged (px, down positive; never past either end).
101
+ // travel: the px between raised and lowered.
102
+ export function sheetDragOffset({ up = false, dy = 0, travel = 0 } = {}) {
103
+ const t = Math.max(0, Number(travel) || 0)
104
+ return up ? Math.round(Math.max(0, Math.min(t, dy))) : Math.round(Math.max(-t, Math.min(0, dy)))
105
+ }
106
+ // Letting go: true when the sheet should change (raise or lower), from the travel or a quick flick.
107
+ export function sheetDragFlips({ up = false, dy = 0, ms = 1000 } = {}) {
108
+ const d = up ? dy : -dy
109
+ if (d >= SHEET_DRAG_FLIP) return true
110
+ return d >= 24 && ms > 0 && d / ms > 0.5 // a flick: 0.5 px/ms
111
+ }
package/ui/live-view.js CHANGED
@@ -326,7 +326,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
326
326
  const who = senderLabel(m)
327
327
  const title = m.from === 'you' ? senderTitle(m) : ''
328
328
  let body
329
+ const cmd = isCommandEntry(m)
329
330
  if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
331
+ else if (cmd) body = commandHtml(m, { esc })
330
332
  else if (m.from === 'you') {
331
333
  const a = messageAttachments(m)
332
334
  // 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
@@ -352,6 +354,8 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
352
354
  // 0.9.11: Copy, once the reply is whole (none while it streams)
353
355
  const cp = m.done ? copyButtonHtml(m.text) : ''
354
356
  if (cp) foot += '<div class="msg-foot">' + cp + '</div>'
357
+ } else if (cmd && m.status && m.status.phase === 'replied') {
358
+ // ran: what it printed is the body's; nothing to add
355
359
  } else if (m.status && m.from === 'you' && isQueued(m.status)) {
356
360
  foot = queuedHtml(m, { esc, may })
357
361
  } else if (m.status) {
@@ -369,6 +373,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
369
373
  return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
370
374
  }
371
375
 
376
+ // A slash command sent from the board (ui/slash-commands.js; src/live-link.js sendCommand): the command
377
+ // in mono, then what Claude Code printed, once the transcript shows it ran (settleCommands).
378
+ export function isCommandEntry(m) { return !!m && m.from === 'you' && m.command === true }
379
+ export function commandHtml(m, { esc }) {
380
+ const ran = !!(m.status && m.status.phase === 'replied')
381
+ const out = typeof m.out === 'string' ? m.out.trim() : ''
382
+ return '<div class="lv-text lv-cmd"><code>' + esc(m.text || '') + '</code></div>' +
383
+ (out ? '<pre class="lv-cmd-out">' + esc(out) + '</pre>' : ran ? '<div class="lv-cmd-out lv-cmd-done">Done</div>' : '')
384
+ }
385
+
372
386
  // 0.9.15: an answer that is still the Stop hook's words, clipped by the mod (280 characters ending in "…")
373
387
  export function partialAnswer(m) { return !!(m && m.from === 'claude' && m.via === 'turn' && typeof m.text === 'string' && /…\s*$/.test(m.text)) }
374
388
 
@@ -565,7 +579,7 @@ export function threadHtml(msgs, opts) {
565
579
  // 0.9.14: Edit on the message; Retry on Claude's "stopped" card (or on the message, when it didn't arrive)
566
580
  const acts = m === tgt ? { retry: m.status.phase === 'failed', edit: true, busy } : null
567
581
  html += entryHtml(m, acts ? { ...opts, lvActs: acts } : opts)
568
- if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
582
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent' && !isCommandEntry(m)) { // a command has no turn: what it printed is in its own bubble
569
583
  // the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
570
584
  html += upTo(next == null ? Infinity : next)
571
585
  html += pendingClaudeHtml(m, m === tgt ? { ...opts, lvActs: { retry: true, busy } } : opts)
@@ -664,7 +678,7 @@ export { ranWithoutAsking }
664
678
  // A content signature, to replace only the entries that changed (the time-ago
665
679
  // text is left out so a minute ticking over doesn't redraw a reply).
666
680
  function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
667
- export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
681
+ export function entrySig(m) { return JSON.stringify([m.text, m.out == null ? null : m.out, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
668
682
 
669
683
  // ------------------------------------------------------------ the DOM keeper
670
684
 
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