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

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.13",
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
@@ -904,10 +904,11 @@ export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
904
904
  // fire), so a turn that has been silent for twice the dormancy window is
905
905
  // treated as over. The mod sees turn.complete for aborted turns too.
906
906
  // 0.8.0: a Codex rollout shows no end for a turn Codex was killed in, either.
907
- if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter) return 'dormant'
907
+ // 2026-11-06: a subagent working is the turn going on, however quiet the session's own hooks are
908
+ if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter && !(s.subagents && s.subagents.n > 0)) return 'dormant'
908
909
  return 'active'
909
910
  }
910
- if (s.backgroundTasks > 0) return 'background'
911
+ if (s.backgroundTasks > 0 || (s.subagents && s.subagents.n > 0)) return 'background' // 2026-11-06: subagents working (the daemon reads them, src/subagents.js), a turn's end missed or not
911
912
  return idle >= dormantAfter ? 'dormant' : 'waiting'
912
913
  }
913
914
 
@@ -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.13' // 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.13",
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
 
@@ -3598,7 +3602,7 @@ del.gd-x { background: var(--gd-del-word); }
3598
3602
 
3599
3603
  html.live-full, html.live-full body { overflow: hidden; overscroll-behavior: none; }
3600
3604
  .live-box.full { --lf-pl: max(12px, env(safe-area-inset-left)); --lf-pr: max(12px, env(safe-area-inset-right)); position: fixed; z-index: 32; left: 0; right: 0; top: var(--lf-top, 0); height: var(--lf-h, 100vh); height: var(--lf-h, 100dvh); margin: 0; border: 0; border-radius: 0; padding: 0 var(--lf-pr) max(10px, env(safe-area-inset-bottom)) var(--lf-pl); display: flex; flex-direction: column; background: var(--bg); box-sizing: border-box; }
3601
- html.lf-kb .live-box.full { padding-bottom: 8px; }
3605
+ html.lf-kb .live-box.full { padding-bottom: calc(8px + var(--lf-acc, 0px)); } /* --lf-acc: iOS 26's keyboard bar floats over the page (app.js KB_ACCESSORY) */
3602
3606
  .live-box.full > * { flex: none; width: 100%; max-width: 860px; margin-left: auto; margin-right: auto; box-sizing: border-box; }
3603
3607
  .live-fbar { display: flex; align-items: center; gap: 6px; min-height: 52px; padding-top: env(safe-area-inset-top); border-bottom: 1px solid var(--line); }
3604
3608
  .live-box.full > .live-fbar { max-width: none; margin: 0 calc(-1 * var(--lf-pr)) 0 calc(-1 * var(--lf-pl)); width: auto; padding-left: max(4px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); background: var(--bg); }
@@ -4542,7 +4546,7 @@ html:not(.live-full) .fs-side { display: none; }
4542
4546
  .fs-side.collapsed .fs-rail { display: none; }
4543
4547
  html.live-full.fs-on .live-box.full { height: calc(var(--lf-h, 100vh) - var(--fs-peek, 56px)); height: calc(var(--lf-h, 100dvh) - 56px - env(safe-area-inset-bottom)); padding-bottom: 6px; }
4544
4548
  html.live-full.lf-kb .fs-side { display: none; }
4545
- html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
4549
+ html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); padding-bottom: calc(6px + var(--lf-acc, 0px)); }
4546
4550
  /* 2026-11-06 typing outside the sheet: it steps aside whether or not a keyboard's height was measured (app.js fitSheetAside) */
4547
4551
  html.live-full.lf-typing .fs-side { display: none; }
4548
4552
  html.live-full.lf-typing.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
@@ -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; }
@@ -5632,3 +5645,22 @@ a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
5632
5645
  .prs-picks { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 8px 12px; margin: 0 0 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
5633
5646
  .prs-picks .btn, .prs-picks .linkish { min-height: 44px; }
5634
5647
  .wt-dec .wt-dec-id { white-space: nowrap; flex: none; }
5648
+
5649
+ /* 2026-11-06 the quiet "Updated" after the board catches up on return (ui/app.js caughtUpCue): a check
5650
+ and a word, out of the way under the top row, no spinner; no slide under reduced motion */
5651
+ .caught-up { position: fixed; top: calc(var(--lf-top, 0px) + env(safe-area-inset-top, 0px) + 56px); left: 50%; transform: translate(-50%, -4px); z-index: 60; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line, rgba(127,127,127,.3)); background: var(--card, var(--bg, #fff)); color: var(--muted, inherit); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .2s ease, transform .2s ease; }
5652
+ .caught-up[hidden] { display: none; }
5653
+ .caught-up .ic { width: 14px; height: 14px; }
5654
+ .caught-up.in { opacity: 1; transform: translate(-50%, 0); }
5655
+ .caught-up.out { opacity: 0; }
5656
+ @media (prefers-reduced-motion: reduce) { .caught-up { transition: none; transform: translate(-50%, 0); } }
5657
+
5658
+ /* 2026-11-06 subagents working (ui/subagents-view.js): a spinner shape and words, in the status pill
5659
+ and as Live's line under the thread; the spinner stands still under reduced motion */
5660
+ .sub-spin { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; margin-right: 5px; vertical-align: -1px; animation: sub-spin 1s linear infinite; flex: none; }
5661
+ @keyframes sub-spin { to { transform: rotate(360deg); } }
5662
+ .lv-subs { display: flex; align-items: center; gap: 8px; min-height: 44px; margin: 6px 0 2px; padding: 6px 10px; border: 1px dashed var(--line-strong, var(--line)); border-radius: 10px; color: var(--text); font-size: 13px; }
5663
+ .lv-subs-what { display: flex; flex-direction: column; min-width: 0; flex: 1; }
5664
+ .lv-subs-step { color: var(--muted); font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
5665
+ .lv-subs-when { font-size: 12px; flex: none; }
5666
+ @media (prefers-reduced-motion: reduce) { .sub-spin { animation: none; border-right-color: currentColor; border-style: dotted; } }
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, 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
5
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, keyboardAccessoryPx, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS, FOLLOW_FRAMES_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
@@ -14,7 +14,7 @@ import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
14
14
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
15
15
  import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
16
16
  import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
17
- import { canStartPull, pullStep, pullRelease } from './pull.js'
17
+ import { canStartPull, pullStep, pullRelease, onPullHead } from './pull.js'
18
18
  import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
19
19
  import { loadSessionOrWait, loadingFromHtml, missingSessionHtml, missingKind, startedHref, startingHtml, STARTING_MS } from './session-missing.js' // 0.9.12: a session this Mac can't load
20
20
  import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
@@ -23,6 +23,7 @@ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepF
23
23
  import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBangStore, bangRowHtml, cmdLabel } from './bang.js' // 0.9.15 "!" commands
24
24
  import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
25
25
  import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
26
+ import { subPillHtml, subStripHtml, subSig } from './subagents-view.js' // 2026-11-06 "2 subagents working"
26
27
  import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
27
28
  import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
28
29
  import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
@@ -43,21 +44,21 @@ import { mdInline, renderSection } from './changelog-md.js'
43
44
  import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
44
45
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
45
46
  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'
47
+ 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
48
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
48
49
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
49
50
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
50
51
  import { cutTitle } from './session-title.js'
51
52
  import { sessionTitle, sessionWhere, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
52
53
  import { statusView, elapsedText } from './live-status.js'
53
- import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
54
+ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS, resumedAfter, catchUp, viewSignature } from './live-wake.js' // 0.9.8 live after sleep; 2026-11-06 catch up on return
54
55
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
55
56
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
56
57
  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
58
  import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
58
59
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
59
60
  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
61
+ 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
62
  import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
62
63
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
63
64
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
@@ -468,7 +469,7 @@ function copiedMark(btn) {
468
469
  btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
469
470
  }
470
471
  // 0.9.13: 'background' reads "2 tasks running", its tip naming them (ui/bgtasks.js)
471
- const pill = (status, s) => status === 'background' && s ? bgPillHtml(s, esc) : '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
472
+ const pill = (status, s) => s && (status === 'active' || status === 'background') && s.subagents && s.subagents.n > 0 ? subPillHtml(s, esc) : status === 'background' && s ? bgPillHtml(s, esc) : '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
472
473
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
473
474
  // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
474
475
  const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an asleep Mac's channel isn't listening
@@ -1137,8 +1138,27 @@ function card(s) {
1137
1138
  // 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
1138
1139
  const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
1139
1140
  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' : '') }
1141
+ // 2026-11-06 a PR range has no This session · Whole folder: it is the PR's own commits
1142
+ function wtBody(sid, extra = {}) { const sc = wtUi.scope.get(sid) || 'session'; return { scope: sc, ...(fileScope.get() === 'folder' && !isPrKey(sc) ? { files: 'folder' } : {}), ...extra } }
1143
+ function wtKey(sid) { const sc = wtUi.scope.get(sid) || 'session'; return sid + '|' + sc + (fileScope.get() === 'folder' && !isPrKey(sc) ? '|folder' : '') }
1144
+ // the ranges the daemon last listed for a session (any range's answer carries them all)
1145
+ function wtScopesOf(sid) { for (const [k, d] of wtUi.data) if (k.startsWith(sid + '|') && d && Array.isArray(d.scopes)) return d.scopes; return [] }
1146
+ function openWtPrMenu(btn, sid) {
1147
+ const cur = wtUi.scope.get(sid)
1148
+ const prs = wtScopesOf(sid).filter((x) => isPrKey(x.key))
1149
+ lastMenuAnchor = btn
1150
+ 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',
1151
+ attrs: { 'data-action': 'wt-scope', 'data-id': sid, 'data-scope': x.key } })), { title: 'Walk through one PR' })
1152
+ }
1153
+ // 2026-11-06 a PR row's Open: where to (the session's walkthrough on this PR, or GitHub)
1154
+ function openPrOpenMenu(btn) {
1155
+ const d = btn.dataset, n = '#' + d.num
1156
+ lastMenuAnchor = btn
1157
+ openActionMenu(btn, [
1158
+ { 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 } },
1159
+ ...(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 } }] : []),
1160
+ ], { title: 'Open ' + n })
1161
+ }
1142
1162
  function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
1143
1163
  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
1164
  function wtRestore(sid) {
@@ -1148,6 +1168,8 @@ function wtRestore(sid) {
1148
1168
  }
1149
1169
  async function wtOpen(sid, scope = null) {
1150
1170
  const s = wtSession(sid)
1171
+ // 2026-11-06 from the PRs page, a session this page hasn't loaded: its page opens on the walkthrough (wtRestore)
1172
+ 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
1173
  if (!s || s.agent) return
1152
1174
  wtUi.sid = sid
1153
1175
  if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
@@ -1238,7 +1260,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
1238
1260
  // 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
1239
1261
  // walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
1240
1262
  async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
1241
- const k = wtKey(sid)
1263
+ let k = wtKey(sid)
1242
1264
  const had = wtUi.data.get(k)
1243
1265
  if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
1244
1266
  if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
@@ -1247,6 +1269,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
1247
1269
  try {
1248
1270
  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
1271
  if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
1272
+ // 2026-11-06 the old 'pr' (remembered from before): the daemon answers for that PR's own range
1273
+ 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
1274
  wtUi.data.set(k, r)
1251
1275
  if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
1252
1276
  // 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
@@ -3519,7 +3543,16 @@ function setFullClass(on) {
3519
3543
  // and the composer stays just above the keyboard. When the keyboard closes the
3520
3544
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3521
3545
  // its pan behind after a send, which put full screen ~300px down the screen).
3522
- 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
+ const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0, raf: 0, until: 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
3547
+ // 2026-11-06 iOS 26's keyboard bar floats over the page (ui/viewport-fit.js ACCESSORY_PX): an iPhone or iPad
3548
+ // whose Safari has a CSS feature that arrived in 26 keeps room for it above the keyboard
3549
+ const KB_ACCESSORY = (() => {
3550
+ try {
3551
+ const ios = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
3552
+ const glass = !!(window.CSS && CSS.supports && (CSS.supports('anchor-name: --lf') || CSS.supports('animation-timeline: scroll()')))
3553
+ return keyboardAccessoryPx({ ios, glass })
3554
+ } catch { return 0 }
3555
+ })()
3523
3556
  // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3524
3557
  function fitSheetAside() {
3525
3558
  const html = document.documentElement, a = document.activeElement
@@ -3532,16 +3565,17 @@ function fitFullViewport() {
3532
3565
  const vv = window.visualViewport
3533
3566
  const html = document.documentElement, st = html.style
3534
3567
  fitSheetAside()
3535
- 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 }
3568
+ if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); st.removeProperty('--lf-acc'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3536
3569
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3537
3570
  const strip = document.getElementById('tabStrip')
3538
3571
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3539
3572
  st.setProperty('--lf-bar', bar + 'px')
3540
3573
  const typing = isTypingTarget(document.activeElement)
3541
- const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
3542
- if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
3574
+ const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing, accessory: KB_ACCESSORY })
3575
+ if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-acc'); html.classList.remove('lf-kb'); return }
3543
3576
  st.setProperty('--lf-h', fit.h + 'px')
3544
3577
  st.setProperty('--lf-top', fit.top + 'px')
3578
+ st.setProperty('--lf-acc', (fit.pad || 0) + 'px') // room kept under the composer for a keyboard bar drawn over the page
3545
3579
  html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3546
3580
  // the keyboard's gone: the page under full screen back where it was before the text box took focus
3547
3581
  if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
@@ -3556,7 +3590,7 @@ function keepTypingInView() {
3556
3590
  if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3557
3591
  const box = el.closest('.live-box.full')
3558
3592
  if (!box) return
3559
- const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
3593
+ const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv, 1, parseFloat(html.style.getPropertyValue('--lf-acc')) || 0)
3560
3594
  if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3561
3595
  }
3562
3596
  // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
@@ -3564,7 +3598,21 @@ function keepTypingInView() {
3564
3598
  // come with no report of its own, and a passing bogus one is put right by the next look.
3565
3599
  function onViewportChange(ev) {
3566
3600
  fitFullViewport()
3567
- if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
3601
+ if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) { settleWhileTyping(1); followFrames() }
3602
+ }
3603
+ // 2026-11-06: and every frame for FOLLOW_FRAMES_MS after the box takes focus or iOS reports a change,
3604
+ // so the keyboard's animation (and iOS's numbers settling a frame or two late) is followed as it goes
3605
+ function followFrames() {
3606
+ if (typeof requestAnimationFrame !== 'function') return
3607
+ lfKb.until = performance.now() + FOLLOW_FRAMES_MS
3608
+ if (lfKb.raf) return
3609
+ const frame = () => {
3610
+ lfKb.raf = 0
3611
+ if (!isTypingTarget(document.activeElement) || !document.documentElement.classList.contains('live-full')) return
3612
+ fitFullViewport()
3613
+ if (performance.now() < lfKb.until) lfKb.raf = requestAnimationFrame(frame)
3614
+ }
3615
+ lfKb.raf = requestAnimationFrame(frame)
3568
3616
  }
3569
3617
  function settleWhileTyping(from = 0) {
3570
3618
  clearTimeout(lfKb.settle)
@@ -3589,6 +3637,7 @@ document.addEventListener('focusin', (e) => {
3589
3637
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3590
3638
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3591
3639
  settleWhileTyping(0)
3640
+ followFrames()
3592
3641
  })
3593
3642
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3594
3643
  // goes down, whatever iOS reports or doesn't on the way.
@@ -3717,7 +3766,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
3717
3766
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
3718
3767
  earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: rows, openGroups: askUi.groups, earlier: (v && v.earlier) || 0, may, mode: s.headless ? s.headless.mode : null, acts: may && !docked && !ownerOffline(s) && !turnSpinning(s, now), retried: retryUi.done, retrying: retryUi.busy }) + '</ol>' +
3719
3768
  '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
3720
- liveJumpHtml() + '</div>' +
3769
+ liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
3721
3770
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
3722
3771
  (ownerOffline(s) ? '<p class="live-off" role="note">' + icon('wifi-off') + '<span>' + esc(awayNote(s)) + ' (' + esc(offlineLine(s)) + '). Sending and answering work again once it’s back.</span></p></section>' : // 0.9.14: no composer or Allow/Deny for an asleep Mac
3723
3772
  (strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
@@ -4224,15 +4273,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
4224
4273
  // Fetch the thread and diff it into the page, even while you type (busy()
4225
4274
  // holds full redraws back, never the thread). A turn that ended also redraws
4226
4275
  // the rest of the page (the pill stops pulsing) once you're not busy.
4227
- async function refreshLive(reason) {
4276
+ async function refreshLive(reason, { force = false } = {}) {
4228
4277
  const id = liveOnScreen()
4229
- if (!id || liveUi.polling) return false
4278
+ // 2026-11-06: a fetch that was in flight when the phone froze the page may never answer; coming
4279
+ // back (force) doesn't wait behind it, and its late answer is dropped (the newer one wins)
4280
+ if (!id || (liveUi.polling && !(force && Date.now() - (liveUi.triedAt || 0) > 1500))) return false
4281
+ const gen = liveUi.gen = (liveUi.gen || 0) + 1
4230
4282
  liveUi.polling = true; liveUi.triedAt = Date.now()
4231
4283
  let v
4232
- try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
4284
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { if (gen === liveUi.gen) liveUi.polling = false }
4285
+ if (gen !== liveUi.gen) return false
4233
4286
  if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
4234
4287
  const s = freshSession(state.sessions.find((x) => x.id === id))
4235
- const sig = liveSignature(v.messages)
4288
+ const sig = liveSignature(v.messages) + '#' + subSig(v.subagents) // 2026-11-06: a subagent's step changes the thread's line
4236
4289
  const before = liveUi.sig[id]
4237
4290
  liveUi.sig[id] = sig
4238
4291
  if (s && before !== sig) {
@@ -4242,6 +4295,8 @@ async function refreshLive(reason) {
4242
4295
  const tmp = document.createElement('div')
4243
4296
  tmp.innerHTML = liveBoxHtml(s, v)
4244
4297
  const fresh = tmp.querySelector('.live-thread')
4298
+ const slot = box.querySelector('.lv-subs-slot'), freshSlot = tmp.querySelector('.lv-subs-slot') // 2026-11-06: subagents working, outside the kept thread
4299
+ if (slot && freshSlot) slot.innerHTML = freshSlot.innerHTML
4245
4300
  if (fresh) {
4246
4301
  const next = cur.nextSibling
4247
4302
  liveKeeper.before(box)
@@ -4261,8 +4316,13 @@ async function refreshLive(reason) {
4261
4316
  // The safety net, and the stream's watchdog, on one slow timer.
4262
4317
  const streamDog = createWatchdog()
4263
4318
  const wakeGate = createWake()
4319
+ let tickAt = Date.now() // 2026-11-06: the timer's own clock, to notice a frozen page (live-wake.js resumedAfter)
4264
4320
  setInterval(() => {
4321
+ const was = tickAt; tickAt = Date.now()
4265
4322
  if (document.visibilityState === 'hidden') return
4323
+ const frozen = resumedAfter(was, tickAt)
4324
+ if (frozen) onWake({ type: 'resume', away: frozen })
4325
+ if (renderDue && !busy()) { renderDue = false; render() } // a redraw a wake-up couldn't make while you typed
4266
4326
  const id = liveOnScreen()
4267
4327
  if (shouldPoll({ visible: true, onLive: !!id, pending: !!(id && liveUi.pending[id]) }) && Date.now() - Math.max(liveUi.okAt[id] || 0, liveUi.triedAt || 0) >= POLL_MS - 250) refreshLive('poll')
4268
4328
  if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
@@ -4270,27 +4330,66 @@ setInterval(() => {
4270
4330
  // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
4271
4331
  // bfcache restore, the network returns): fetch what's on screen at once, and
4272
4332
  // open a new stream if the old one missed its pings.
4273
- function refreshView(reason) {
4274
- if (liveOnScreen()) refreshLive(reason)
4275
- if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
4276
- refreshHealth()
4333
+ let renderDue = false // 2026-11-06: a wake-up's redraw held back while you type (busy()); made once you stop
4334
+ let freshAt = Date.now() // when the view's data was last known current (a refetch that answered)
4335
+ const WAKES = new Set(['visible', 'focus', 'online', 'pageshow-bfcache', 'resume', 'reconnect', 'resync', 'relay-switch'])
4336
+ async function refreshView(reason) {
4337
+ const editing = state.route && ['settings', 'setup'].includes(state.route.name)
4338
+ const id = liveOnScreen()
4339
+ const before = viewSignature(state.sessions, id ? liveUi.sig[id] : '')
4340
+ const wake = WAKES.has(reason)
4341
+ if (reason === 'reconnect' && Date.now() - wakeAt < 2500) return // the stream a wake-up just opened: that wake-up is reading the view
4342
+ const jobs = [id ? refreshLive(reason, { force: wake }) : null, refreshHealth()]
4343
+ if (editing) { /* Settings reads its own sections */ } else if (busy()) renderDue = true
4344
+ else jobs.push(render())
4345
+ await Promise.all(jobs.map((p) => Promise.resolve(p).catch(() => null)))
4346
+ if (!wake) return
4347
+ freshAt = Date.now()
4348
+ const id2 = liveOnScreen()
4349
+ if (viewSignature(state.sessions, id2 ? liveUi.sig[id2] : '') !== before) caughtUpCue()
4350
+ }
4351
+ // The quiet "Updated" a wake-up's refetch shows when it brought something new: a word and a check
4352
+ // (never color alone), no spinner, gone in a moment; still under reduced motion (app.css).
4353
+ function caughtUpCue() {
4354
+ let el = document.getElementById('caughtUp')
4355
+ if (!el) {
4356
+ el = document.createElement('div')
4357
+ el.id = 'caughtUp'; el.className = 'caught-up'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite')
4358
+ el.innerHTML = icon('check') + '<span>Updated</span>'
4359
+ document.body.appendChild(el)
4360
+ }
4361
+ el.hidden = false; el.classList.remove('out'); void el.offsetWidth; el.classList.add('in')
4362
+ clearTimeout(caughtUpCue.t)
4363
+ caughtUpCue.t = setTimeout(() => { el.classList.remove('in'); el.classList.add('out'); caughtUpCue.t = setTimeout(() => { el.hidden = true }, 400) }, 1600)
4277
4364
  }
4278
4365
  let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
4279
4366
  function onWake(ev) {
4280
4367
  if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
4281
- const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4368
+ const why = ev.type === 'resume' ? 'resume' : wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
4282
4369
  if (!why) return
4283
4370
  liveTick() // a stale timer turns into "Reconnecting…" before anything else
4284
- const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
4285
- const away = awayAt ? Date.now() - awayAt : 0
4371
+ const now = Date.now()
4372
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, now, HEARTBEAT_MS + 2000)
4373
+ const away = Math.max(awayAt ? now - awayAt : 0, ev.away || 0)
4286
4374
  awayAt = 0
4287
- // a click back into the window a moment later isn't a wake-up; coming back after a while, a restore or the network is
4288
- if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
4289
- if (!wakeGate.due()) return
4290
- if (why === 'pageshow-bfcache' || dead) connect()
4375
+ // a click back into the window a moment later isn't a wake-up; coming back after a while, a
4376
+ // restore, the network or a frozen page is (live-wake.js catchUp)
4377
+ const plan = catchUp({ why, away, dead, freshAt: Math.max(freshAt, streamDog.lastAt || 0), now })
4378
+ if (!plan.refetch) return
4379
+ // the timer's freeze and the page's own events arrive together: one catch-up
4380
+ if (now - wakeAt < 2500 && !dead) return
4381
+ if (!wakeGate.due() && !plan.reconnect) return
4382
+ wakeAt = now
4383
+ if (plan.reconnect && now - (connect.at || 0) > 2000) connect()
4291
4384
  refreshView(why)
4292
4385
  }
4386
+ let wakeAt = 0
4293
4387
  for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
4388
+ // 2026-11-06: the relay moved this board to another Mac: a new stream from it, and its data
4389
+ window.addEventListener('leftoff-relay', (e) => {
4390
+ if (!(e.detail && e.detail.switched)) return
4391
+ clearTimeout(onWake.sw); onWake.sw = setTimeout(() => { connect(); refreshView('relay-switch') }, 300)
4392
+ })
4294
4393
  // 0.9.15 (ui/away.js): when this board was last on screen here
4295
4394
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
4296
4395
  window.addEventListener('pagehide', () => presence.here())
@@ -8582,14 +8681,14 @@ function keepPlace() {
8582
8681
  document.addEventListener('touchstart', (e) => {
8583
8682
  if (refreshing || e.touches.length !== 1) { pull = null; return }
8584
8683
  const t = e.touches[0]
8585
- pull = canStartPull({ coarse: coarse.matches, narrow: narrow.matches, scrollY: window.scrollY || document.scrollingElement.scrollTop, target: e.target, overlayOpen: overlayOpen() }) ? { x: t.clientX, y: t.clientY, step: null } : null
8684
+ pull = canStartPull({ coarse: coarse.matches, narrow: narrow.matches, scrollY: window.scrollY || document.scrollingElement.scrollTop, target: e.target, overlayOpen: overlayOpen() }) ? { x: t.clientX, y: t.clientY, step: null, head: onPullHead(e.target) } : null
8586
8685
  }, { passive: true })
8587
8686
  document.addEventListener('touchmove', (e) => {
8588
8687
  if (!pull || e.touches.length !== 1) return
8589
8688
  const t = e.touches[0]
8590
8689
  const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
8591
8690
  if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
8592
- if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
8691
+ if (!pull.head && (window.scrollY || 0) > 0) { pull = null; paint(null); return } // a pull from the bars at the top works scrolled anywhere
8593
8692
  if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
8594
8693
  pull.step = step
8595
8694
  paint(step)
@@ -9093,10 +9192,13 @@ document.addEventListener('click', async (ev) => {
9093
9192
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
9094
9193
  if (action === 'tab-pin') return togglePin(id)
9095
9194
  // 0.9.17 Walk me through it
9096
- if (action === 'wt-open') return wtOpen(id, btn.dataset.scope || null)
9195
+ if (action === 'pr-open') return openPrOpenMenu(btn)
9196
+ if (action === 'pr-gh') { closeActionMenu(); const u = prSafeUrl(btn.dataset.url); if (u) window.open(u, '_blank', 'noopener,noreferrer'); return }
9197
+ if (action === 'wt-open') { if (btn.closest('.lo-actmenu')) closeActionMenu(); return wtOpen(id, btn.dataset.scope || null) }
9097
9198
  if (action === 'wt-close') return wtClose()
9098
9199
  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) }
9200
+ if (action === 'wt-prs') return openWtPrMenu(btn, btn.dataset.id || wtUi.sid)
9201
+ 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
9202
  if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
9101
9203
  if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
9102
9204
  if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
@@ -10476,6 +10578,7 @@ let stream = null
10476
10578
  function connect() {
10477
10579
  if (stream) { try { stream.close() } catch {} }
10478
10580
  const es = stream = new EventSource('/v1/stream')
10581
+ connect.at = Date.now()
10479
10582
  const again = connect.opened // a reconnect: events may have been missed while it was down
10480
10583
  streamDog.opened()
10481
10584
  // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
@@ -10486,6 +10589,9 @@ function connect() {
10486
10589
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
10487
10590
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
10488
10591
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
10592
+ // 2026-11-06: the relay's poll lost events (its Mac restarted, another Mac took over, or more
10593
+ // happened than the daemon keeps): read the view again (ui/relay-client.js)
10594
+ es.addEventListener('resync', () => { if (stream === es) refreshView('resync') })
10489
10595
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
10490
10596
  // port; if the browser gave up on the stream, open a new one.
10491
10597
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
package/ui/live-wake.js CHANGED
@@ -94,3 +94,50 @@ export function createWatchdog({ now = () => Date.now(), limit = STREAM_DEAD_MS
94
94
  get attempt() { return attempt },
95
95
  }
96
96
  }
97
+
98
+ // 2026-11-06 the board catches up when you come back ("opening Leftoff on my phone, if it's been
99
+ // opened and not closed, doesn't always pick up the latest data"). iOS freezes a Home Screen app in
100
+ // the background: its timers stop, its event stream's socket is cut without an error, and on the way
101
+ // back it doesn't always fire visibilitychange, focus or pageshow. Coming back after a few seconds
102
+ // the old stream still looked alive (its last ping was under STREAM_DEAD_MS ago), a refetch already
103
+ // in flight when the page froze held every new one back, and a redraw skipped while you had a draft
104
+ // was never made. So:
105
+ // - the board's one-second timer notices it was frozen (it missed RESUME_GAP_MS) and treats that as
106
+ // coming back, whether or not the page said so
107
+ // - coming back after RECONNECT_AWAY_MS (or the network returning, a restore, a frozen page) always
108
+ // opens a new stream, whatever the old one's last ping said
109
+ // - a relay poll that answers "reset" (the Mac restarted, or another Mac took over) or "gap" (more
110
+ // happened than the daemon keeps) refetches the view, since the events in between are gone
111
+ export const RESUME_GAP_MS = 5000
112
+ export const RECONNECT_AWAY_MS = 10000
113
+ export const FRESH_MS = 30000
114
+
115
+ // How long the page was frozen, by a timer that should have ticked every second: 0 when it ticked.
116
+ export function resumedAfter(prevTick, now = Date.now(), gap = RESUME_GAP_MS) {
117
+ const d = now - (Number(prevTick) || 0)
118
+ return prevTick && d > gap ? d : 0
119
+ }
120
+
121
+ // What a wake-up does: open a new stream (reconnect) and read the view again (refetch).
122
+ // why: wakeReason's word, or 'resume' (the timer saw a freeze); away: ms in the background;
123
+ // dead: the stream missed its pings; freshAt: when the view's data was last known current
124
+ // (a fetch that answered, or the stream's last ping)
125
+ export function catchUp({ why, away = 0, dead = false, freshAt = 0, now = Date.now() } = {}) {
126
+ if (!why) return { reconnect: false, refetch: false }
127
+ const hard = why === 'pageshow-bfcache' || why === 'online' || why === 'resume'
128
+ const reconnect = !!(dead || hard || away >= RECONNECT_AWAY_MS)
129
+ const stale = !freshAt || now - freshAt > FRESH_MS
130
+ const refetch = !!(reconnect || away >= AWAY_MS || stale)
131
+ return { reconnect, refetch }
132
+ }
133
+
134
+ // A relay poll's answer: were events lost between `after` and this answer?
135
+ export function pollLost(after, j) {
136
+ if (!j || after == null || after < 0) return false
137
+ return !!(j.reset || j.gap)
138
+ }
139
+
140
+ // What the view's data looks like, to tell a refetch that changed something (the quiet "Updated").
141
+ export function viewSignature(sessions, liveSig = '') {
142
+ return (sessions || []).map((s) => [s && s.id, s && s.status, s && s.title, s && s.lastActivityAt, s && s.subagents ? s.subagents.n + ':' + (s.subagents.latest ? s.subagents.latest.at : '') : ''].join(',')).sort().join('|') + '#' + (liveSig || '')
143
+ }