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