@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.
- package/CHANGELOG.md +5 -0
- package/bin/leftoff.js +577 -571
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +21 -2
- package/ui/app.js +108 -28
- package/ui/live-wake.js +47 -0
- package/ui/pull.js +16 -2
- package/ui/relay-client.js +2 -0
- package/ui/subagents-view.js +41 -0
- package/ui/viewport-fit.js +27 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
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
|
-
|
|
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
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
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
|
|
4308
|
-
const
|
|
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
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
if (
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
package/ui/relay-client.js
CHANGED
|
@@ -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 || '') : '') : '' }
|
package/ui/viewport-fit.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|