@leftoff/cli 0.9.18-beta.12 → 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.12",
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.12' // 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.12",
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
@@ -3602,7 +3602,7 @@ del.gd-x { background: var(--gd-del-word); }
3602
3602
 
3603
3603
  html.live-full, html.live-full body { overflow: hidden; overscroll-behavior: none; }
3604
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; }
3605
- 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) */
3606
3606
  .live-box.full > * { flex: none; width: 100%; max-width: 860px; margin-left: auto; margin-right: auto; box-sizing: border-box; }
3607
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); }
3608
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); }
@@ -4546,7 +4546,7 @@ html:not(.live-full) .fs-side { display: none; }
4546
4546
  .fs-side.collapsed .fs-rail { display: none; }
4547
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; }
4548
4548
  html.live-full.lf-kb .fs-side { display: none; }
4549
- 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)); }
4550
4550
  /* 2026-11-06 typing outside the sheet: it steps aside whether or not a keyboard's height was measured (app.js fitSheetAside) */
4551
4551
  html.live-full.lf-typing .fs-side { display: none; }
4552
4552
  html.live-full.lf-typing.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
@@ -5645,3 +5645,22 @@ a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
5645
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); }
5646
5646
  .prs-picks .btn, .prs-picks .linkish { min-height: 44px; }
5647
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…
@@ -50,7 +51,7 @@ import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel
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'
@@ -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
@@ -3542,7 +3543,16 @@ function setFullClass(on) {
3542
3543
  // and the composer stays just above the keyboard. When the keyboard closes the
3543
3544
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3544
3545
  // its pan behind after a send, which put full screen ~300px down the screen).
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
+ 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
+ })()
3546
3556
  // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3547
3557
  function fitSheetAside() {
3548
3558
  const html = document.documentElement, a = document.activeElement
@@ -3555,16 +3565,17 @@ function fitFullViewport() {
3555
3565
  const vv = window.visualViewport
3556
3566
  const html = document.documentElement, st = html.style
3557
3567
  fitSheetAside()
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 }
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 }
3559
3569
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3560
3570
  const strip = document.getElementById('tabStrip')
3561
3571
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3562
3572
  st.setProperty('--lf-bar', bar + 'px')
3563
3573
  const typing = isTypingTarget(document.activeElement)
3564
- const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
3565
- 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 }
3566
3576
  st.setProperty('--lf-h', fit.h + 'px')
3567
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
3568
3579
  html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
3569
3580
  // the keyboard's gone: the page under full screen back where it was before the text box took focus
3570
3581
  if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
@@ -3579,7 +3590,7 @@ function keepTypingInView() {
3579
3590
  if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
3580
3591
  const box = el.closest('.live-box.full')
3581
3592
  if (!box) return
3582
- 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)
3583
3594
  if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
3584
3595
  }
3585
3596
  // Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
@@ -3587,7 +3598,21 @@ function keepTypingInView() {
3587
3598
  // come with no report of its own, and a passing bogus one is put right by the next look.
3588
3599
  function onViewportChange(ev) {
3589
3600
  fitFullViewport()
3590
- 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)
3591
3616
  }
3592
3617
  function settleWhileTyping(from = 0) {
3593
3618
  clearTimeout(lfKb.settle)
@@ -3612,6 +3637,7 @@ document.addEventListener('focusin', (e) => {
3612
3637
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3613
3638
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3614
3639
  settleWhileTyping(0)
3640
+ followFrames()
3615
3641
  })
3616
3642
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3617
3643
  // goes down, whatever iOS reports or doesn't on the way.
@@ -3740,7 +3766,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
3740
3766
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
3741
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>' +
3742
3768
  '<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
3743
- liveJumpHtml() + '</div>' +
3769
+ liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
3744
3770
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
3745
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
3746
3772
  (strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
@@ -4247,15 +4273,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
4247
4273
  // Fetch the thread and diff it into the page, even while you type (busy()
4248
4274
  // holds full redraws back, never the thread). A turn that ended also redraws
4249
4275
  // the rest of the page (the pill stops pulsing) once you're not busy.
4250
- async function refreshLive(reason) {
4276
+ async function refreshLive(reason, { force = false } = {}) {
4251
4277
  const id = liveOnScreen()
4252
- 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
4253
4282
  liveUi.polling = true; liveUi.triedAt = Date.now()
4254
4283
  let v
4255
- 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
4256
4286
  if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
4257
4287
  const s = freshSession(state.sessions.find((x) => x.id === id))
4258
- 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
4259
4289
  const before = liveUi.sig[id]
4260
4290
  liveUi.sig[id] = sig
4261
4291
  if (s && before !== sig) {
@@ -4265,6 +4295,8 @@ async function refreshLive(reason) {
4265
4295
  const tmp = document.createElement('div')
4266
4296
  tmp.innerHTML = liveBoxHtml(s, v)
4267
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
4268
4300
  if (fresh) {
4269
4301
  const next = cur.nextSibling
4270
4302
  liveKeeper.before(box)
@@ -4284,8 +4316,13 @@ async function refreshLive(reason) {
4284
4316
  // The safety net, and the stream's watchdog, on one slow timer.
4285
4317
  const streamDog = createWatchdog()
4286
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)
4287
4320
  setInterval(() => {
4321
+ const was = tickAt; tickAt = Date.now()
4288
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
4289
4326
  const id = liveOnScreen()
4290
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')
4291
4328
  if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
@@ -4293,27 +4330,66 @@ setInterval(() => {
4293
4330
  // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
4294
4331
  // bfcache restore, the network returns): fetch what's on screen at once, and
4295
4332
  // open a new stream if the old one missed its pings.
4296
- function refreshView(reason) {
4297
- if (liveOnScreen()) refreshLive(reason)
4298
- if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
4299
- 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)
4300
4364
  }
4301
4365
  let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
4302
4366
  function onWake(ev) {
4303
4367
  if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
4304
- 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 })
4305
4369
  if (!why) return
4306
4370
  liveTick() // a stale timer turns into "Reconnecting…" before anything else
4307
- const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
4308
- 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)
4309
4374
  awayAt = 0
4310
- // 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
4311
- if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
4312
- if (!wakeGate.due()) return
4313
- 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()
4314
4384
  refreshView(why)
4315
4385
  }
4386
+ let wakeAt = 0
4316
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
+ })
4317
4393
  // 0.9.15 (ui/away.js): when this board was last on screen here
4318
4394
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
4319
4395
  window.addEventListener('pagehide', () => presence.here())
@@ -8605,14 +8681,14 @@ function keepPlace() {
8605
8681
  document.addEventListener('touchstart', (e) => {
8606
8682
  if (refreshing || e.touches.length !== 1) { pull = null; return }
8607
8683
  const t = e.touches[0]
8608
- 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
8609
8685
  }, { passive: true })
8610
8686
  document.addEventListener('touchmove', (e) => {
8611
8687
  if (!pull || e.touches.length !== 1) return
8612
8688
  const t = e.touches[0]
8613
8689
  const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
8614
8690
  if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
8615
- 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
8616
8692
  if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
8617
8693
  pull.step = step
8618
8694
  paint(step)
@@ -10502,6 +10578,7 @@ let stream = null
10502
10578
  function connect() {
10503
10579
  if (stream) { try { stream.close() } catch {} }
10504
10580
  const es = stream = new EventSource('/v1/stream')
10581
+ connect.at = Date.now()
10505
10582
  const again = connect.opened // a reconnect: events may have been missed while it was down
10506
10583
  streamDog.opened()
10507
10584
  // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
@@ -10512,6 +10589,9 @@ function connect() {
10512
10589
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
10513
10590
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
10514
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') })
10515
10595
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
10516
10596
  // port; if the browser gave up on the stream, open a new one.
10517
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
+ }
package/ui/pull.js CHANGED
@@ -9,10 +9,24 @@ export const PULL_MAX = 110 // how far the spinner travels at most
9
9
  // sideways scrollers, and anything open on top of the page.
10
10
  export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, .fs-side, [data-no-pull]'
11
11
 
12
- // The phone layout on a touch screen, at the very top of the page, from a
13
- // place that isn't a field or an overlay, and with no overlay open.
12
+ // 2026-11-06 ("it's really hard to do that when you're at the bottom of a long message chain, because
13
+ // you have to scroll all the way to the top"): the bars at the top of the screen are a handle of their
14
+ // own. A pull that starts on the top row, a session's head or the Live box's head refreshes wherever
15
+ // the page or the chat is scrolled to. Fields, menus, sideways scrollers and the sheets still never
16
+ // start one (PULL_HEAD_SKIP: PULL_SKIP without the Live box, whose head is one of the bars).
17
+ export const PULL_HEAD = 'header.top, .s-head, .fs-head, .live-head, .live-fbar' // .live-fbar: full-screen Live's slim bar
18
+ export const PULL_HEAD_SKIP = PULL_SKIP.split(', ').filter((x) => x !== '.live-box' && x !== 'pre').join(', ') + ', button.chip-x, [role="slider"]'
19
+ export function onPullHead(target) {
20
+ if (!target || typeof target.closest !== 'function') return false
21
+ return !!target.closest(PULL_HEAD) && !target.closest(PULL_HEAD_SKIP)
22
+ }
23
+
24
+ // The phone layout on a touch screen, at the very top of the page (or on one of
25
+ // the bars at the top, onPullHead), from a place that isn't a field or an
26
+ // overlay, and with no overlay open.
14
27
  export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
15
28
  if (!coarse || !narrow || overlayOpen) return false
29
+ if (onPullHead(target)) return true
16
30
  if (scrollY > 0) return false
17
31
  if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
18
32
  if (scrolledAbove(target)) return false
@@ -364,7 +364,9 @@ class RelayEventSource extends EventTarget {
364
364
  if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
365
365
  this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
366
366
  const first = this.seq == null
367
+ const lost = !first && (j.reset || j.gap) // 2026-11-06: events this browser never got (the Mac restarted, another took over, or more happened than it keeps)
367
368
  this.seq = j.seq
369
+ if (lost) this.dispatchEvent(new MessageEvent('resync', { data: JSON.stringify({ reset: !!j.reset, gap: !!j.gap }) }))
368
370
  if (!first) for (const e of j.events || []) {
369
371
  const ev = new MessageEvent(e.event, { data: JSON.stringify(e.data) })
370
372
  this.dispatchEvent(ev)
@@ -0,0 +1,41 @@
1
+ // 2026-11-06 subagents working, on the board (src/subagents.js reads them): the session's status says
2
+ // "2 subagents working" instead of looking idle, and Live shows a line with what the newest one is
3
+ // doing ("Bash: pnpm test"). Words and a shape (the spinner), never color alone.
4
+
5
+ export function subCount(s) { const n = s && s.subagents ? Number(s.subagents.n) || 0 : 0; return n > 0 ? n : 0 }
6
+ export function subLabel(sub) {
7
+ const n = sub ? Number(sub.n) || 0 : 0
8
+ return n > 0 ? n + (n === 1 ? ' subagent working' : ' subagents working') : ''
9
+ }
10
+ export function subStep(sub) {
11
+ const l = sub && sub.latest
12
+ if (!l) return ''
13
+ return String(l.tool ? l.tool + (l.detail ? ': ' + l.detail : '') : l.detail || '').replace(/\s+/g, ' ').trim().slice(0, 160)
14
+ }
15
+ const who = (a) => (a && (a.label || (a.type ? a.type + ' agent' : ''))) || 'a subagent'
16
+ export function subTip(sub) {
17
+ const list = (sub && Array.isArray(sub.agents) ? sub.agents : []).map((a) => who(a) + (a && a.bg ? ' (background)' : ''))
18
+ const step = subStep(sub)
19
+ return (list.length ? list.join('\n') : subLabel(sub)) + (step ? '\nNow: ' + step : '')
20
+ }
21
+ // The status pill while subagents work (a session in a turn or with background work)
22
+ export function subPillHtml(s, esc) {
23
+ const sub = s && s.subagents
24
+ const label = subLabel(sub)
25
+ if (!label) return ''
26
+ const tip = subTip(sub)
27
+ return '<span class="pill s-active sub-pill" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(label + ': ' + tip.replace(/\n/g, ', ')) + '"><span class="sub-spin" aria-hidden="true"></span>' + esc(label) + '</span>'
28
+ }
29
+ // Live's line under the thread
30
+ export function subStripHtml(sub, esc, { now = Date.now() } = {}) {
31
+ const label = subLabel(sub)
32
+ if (!label) return ''
33
+ const step = subStep(sub)
34
+ const at = sub.latest && sub.latest.at ? Number(sub.latest.at) : 0
35
+ const ago = at ? Math.max(0, Math.round((now - at) / 1000)) : null
36
+ const when = ago == null ? '' : ago < 5 ? 'just now' : ago < 90 ? ago + 's ago' : Math.round(ago / 60) + 'm ago'
37
+ return '<div class="lv-subs" role="status" data-tip="' + esc(subTip(sub)) + '"><span class="sub-spin" aria-hidden="true"></span><span class="lv-subs-what"><b>' + esc(label) + '</b>' +
38
+ (step ? '<span class="lv-subs-step">' + esc(step) + '</span>' : '') + '</span>' + (when ? '<span class="faint lv-subs-when">' + esc(when) + '</span>' : '') + '</div>'
39
+ }
40
+ // What Live's refetch compares, beside the thread's own signature
41
+ export function subSig(sub) { return sub && sub.n > 0 ? sub.n + ':' + (sub.latest ? sub.latest.at + (sub.latest.tool || '') + (sub.latest.detail || '') : '') : '' }
@@ -22,13 +22,14 @@ export function isTypingTarget(el) {
22
22
  export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
23
23
 
24
24
  // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
- // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
25
+ // accessory: px a keyboard bar drawn over the page covers (keyboardAccessoryPx).
26
+ // -> { h, top, kb, pad? } in CSS px (pad only when there's room to keep) (null without a visualViewport: the CSS falls back to 100dvh).
26
27
  // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
28
  // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
29
  // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
30
  // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
31
  // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
31
- export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
32
+ export function fullViewportFit({ innerHeight, vv, typing = false, accessory = 0 } = {}) {
32
33
  if (!vv || !(Number(innerHeight) > 0)) return null
33
34
  const ih = Number(innerHeight)
34
35
  const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
@@ -37,15 +38,32 @@ export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
37
38
  if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
38
39
  const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
39
40
  const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
40
- return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
41
+ // pad: room kept clear at the bottom for a keyboard bar drawn over the page (iOS 26's form bar), only with a keyboard up
42
+ const pad = kb ? Math.max(0, Math.round(Number(accessory) || 0)) : 0
43
+ const fit = { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
44
+ if (pad) fit.pad = pad
45
+ return fit
46
+ }
47
+
48
+ // 2026-11-06 (Joel, iPhone: "So close but not quite": the sheet stepped aside, but only the reply
49
+ // box's top edge showed above the keyboard's ^ v ✓ bar). Since iOS 26 that bar floats over the
50
+ // page (Liquid Glass: it and the band behind it show the page through), and the visual viewport iOS
51
+ // reports runs down behind it, so a reply box placed at the reported bottom sits under the bar. No
52
+ // browser number says how tall the bar is, so on an iPhone or iPad whose Safari is 26 or later
53
+ // (glass: a CSS feature that arrived in Safari 26) full screen keeps this much clear at its bottom
54
+ // while a keyboard is up. On older iOS the bar was outside the reported viewport: nothing kept.
55
+ // Measured from the screenshot: the band from the bar's top edge to the keys is about 68px.
56
+ export const ACCESSORY_PX = 64
57
+ export function keyboardAccessoryPx({ ios = false, glass = false } = {}) {
58
+ return ios && glass ? ACCESSORY_PX : 0
41
59
  }
42
60
 
43
61
  // How far full screen's composer sits outside what you can see (the visual viewport, in the same
44
62
  // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
45
63
  // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
46
- export function outOfView(rect, vv, slack = 1) {
64
+ export function outOfView(rect, vv, slack = 1, pad = 0) {
47
65
  if (!rect || !vv || !(Number(vv.height) > 0)) return 0
48
- const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
66
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height) - Math.max(0, Number(pad) || 0) // pad: covered by a keyboard bar
49
67
  if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
50
68
  if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
51
69
  return 0
@@ -57,6 +75,10 @@ export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
57
75
  // The same beats after every report while a text box has focus: a keyboard swap's last change can
58
76
  // come with no event of its own, so the board looks again a few times after each one.
59
77
  export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
78
+ // And every frame for this long after the box takes focus and after each report: iOS animates the
79
+ // keyboard (and pans) for ~0.3–0.5 s, part of it with no event, and its numbers settle a frame or
80
+ // two after the event that announced them (WebKit bug 237851: offsetTop read 0 mid-animation).
81
+ export const FOLLOW_FRAMES_MS = 1000
60
82
 
61
83
  // 2026-11-06 (the phone's session sheet covered the reply box while typing; "sometimes I have to
62
84
  // scroll that down, and then the message box appears"): the sheet went away only when the board