@leftoff/cli 0.9.18-beta.12 → 0.9.18-beta.14
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 +12 -0
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +771 -684
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +209 -5
- package/mod/hooks/register.js +8 -2
- package/package.json +1 -1
- package/ui/app.css +123 -4
- package/ui/app.js +332 -146
- package/ui/cp-fold.js +20 -0
- package/ui/decisions.js +2 -1
- package/ui/digest-versions.js +57 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +107 -0
- package/ui/icons.js +1 -0
- package/ui/live-wake.js +47 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/pull.js +16 -2
- package/ui/relay-client.js +2 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +38 -21
- package/ui/settings-nav.js +1 -1
- package/ui/subagents-view.js +41 -0
- package/ui/viewport-fit.js +27 -5
- package/ui/walk-spots.js +35 -1
package/ui/app.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Leftoff web UI: plain JS, no build step, no CDN. Talks to the daemon's
|
|
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
|
+
import { mcpSetupHtml, mcpStateHtml } from './mcp-setup.js'
|
|
5
|
+
import { paintDigestVersion } from './digest-versions.js'
|
|
4
6
|
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
|
|
7
|
+
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
8
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
7
9
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
8
10
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -14,7 +16,7 @@ import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
|
|
|
14
16
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
15
17
|
import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
16
18
|
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
17
|
-
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
19
|
+
import { canStartPull, pullStep, pullRelease, onPullHead } from './pull.js'
|
|
18
20
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
19
21
|
import { loadSessionOrWait, loadingFromHtml, missingSessionHtml, missingKind, startedHref, startingHtml, STARTING_MS } from './session-missing.js' // 0.9.12: a session this Mac can't load
|
|
20
22
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
@@ -23,6 +25,7 @@ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepF
|
|
|
23
25
|
import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBangStore, bangRowHtml, cmdLabel } from './bang.js' // 0.9.15 "!" commands
|
|
24
26
|
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
27
|
import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
|
|
28
|
+
import { subPillHtml, subStripHtml, subSig } from './subagents-view.js' // 2026-11-06 "2 subagents working"
|
|
26
29
|
import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
|
|
27
30
|
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
31
|
import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
|
|
@@ -50,22 +53,23 @@ import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel
|
|
|
50
53
|
import { cutTitle } from './session-title.js'
|
|
51
54
|
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
55
|
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
|
|
56
|
+
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
57
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
55
58
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
56
59
|
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
60
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
|
|
58
61
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
59
62
|
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,
|
|
63
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, revNextTip, 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
64
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
62
|
-
import { spotsFor,
|
|
65
|
+
import { spotsFor, spotFrom, spotTip, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
63
66
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
64
67
|
import { lastReply, ghostShows } from './ghost.js'
|
|
65
68
|
import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
|
|
66
69
|
import { digestHeadline, todayHidden, hideToday, todayCardHtml } from './today.js' // 0.9.16 the board's Today card // 0.9.16 the suggested next message // 0.9.16 the session panel beside full screen
|
|
67
70
|
import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
|
|
68
71
|
import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
|
|
72
|
+
import { isHtmlDoc, remoteRefs, previewDoc, previewBarHtml, PREVIEW_PAGE, VIEW_KEY as HP_VIEW_KEY } from './html-preview.js'
|
|
69
73
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
70
74
|
import { widState, whatItDidSection, copyText as widCopyText, loadError as widError, hiddenText as widHidden, slotSource as widSlot } from './what-it-did.js' // 0.9.8 What it did
|
|
71
75
|
import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
|
|
@@ -78,7 +82,9 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
|
|
|
78
82
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
79
83
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
80
84
|
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
81
|
-
import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
85
|
+
import { cpFoldSummary, workItems } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
86
|
+
import { digestBodyHtml } from './render-digest.js' // 2026-11-06 the digest renderer, shared with leftoff export
|
|
87
|
+
import { digestExportHtml, decisionsExportHtml, walkthroughExportHtml, exportFileName } from './render-export.js' // 2026-11-06 Save as HTML (the same file leftoff export writes)
|
|
82
88
|
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
83
89
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
84
90
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
@@ -468,7 +474,7 @@ function copiedMark(btn) {
|
|
|
468
474
|
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
469
475
|
}
|
|
470
476
|
// 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>'
|
|
477
|
+
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
478
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
473
479
|
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
474
480
|
const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an asleep Mac's channel isn't listening
|
|
@@ -539,11 +545,15 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
539
545
|
}
|
|
540
546
|
// 0.9.15: what the session is for and how far along, above its latest step
|
|
541
547
|
const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
|
|
542
|
-
|
|
548
|
+
// 2026-11-06: what the session's commands show got done (commits, PRs, tests), above where it stopped
|
|
549
|
+
const wl = workItems(cp)
|
|
550
|
+
const doneRow = wl.length ? '<dt>Got done</dt><dd class="cp-done"><ul>' + wl.map((x) => '<li class="dn dn-' + x.kind + '">' + fmtCode(x.text) + '</li>').join('') + '</ul></dd>' : ''
|
|
551
|
+
if (part === 'tail') return '<dl class="cp cp-tail' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' + doneRow + '<dt>Stopped</dt>' + dd(cp.stoppedAt) + (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') + '</dl>'
|
|
543
552
|
if (part === 'head') return (goal || String(cp.doing || '').trim() ? '<dl class="cp cp-head">' + (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') + (String(cp.doing || '').trim() ? '<dt>Doing</dt>' + dd(cp.doing) : '') + '</dl>' : '') + (n ? '<div class="cpx">' + cpExtras(cp, paths) + '</div>' : '')
|
|
544
553
|
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
545
554
|
(goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
|
|
546
555
|
(rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
|
|
556
|
+
(rows ? '' : doneRow) +
|
|
547
557
|
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
548
558
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
549
559
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · ' + (cp.generator.by === 'heuristic' ? '' : 'by ') + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) : '') + '</div>' : '') +
|
|
@@ -1348,11 +1358,30 @@ function wtRestoreView(node) {
|
|
|
1348
1358
|
wtStuck(node.querySelector('.wt-scroll'))
|
|
1349
1359
|
}
|
|
1350
1360
|
// The reviewed marks and the count, in place (no redraw).
|
|
1361
|
+
// the steps reviewed, as saved plus the marks still on their way
|
|
1362
|
+
function wtRevNow(s, data) {
|
|
1363
|
+
const rev = reviewedSet(s, data.fingerprint)
|
|
1364
|
+
for (const x of wtUi.pending) if (x.fp === data.fingerprint) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
|
|
1365
|
+
return rev
|
|
1366
|
+
}
|
|
1367
|
+
// the slides as revNextState reads them: each step's id and number
|
|
1368
|
+
function wtDeckSteps(slides, data) {
|
|
1369
|
+
const by = new Map((data.steps || []).map((x) => [x.id, x]))
|
|
1370
|
+
return slides.map((x) => { const st = x.dataset.step && by.get(x.dataset.step); return { step: st ? { id: st.id, n: st.n } : null } })
|
|
1371
|
+
}
|
|
1372
|
+
// Mark reviewed & next / & step N / & finish, for the slide on screen and what's reviewed now
|
|
1373
|
+
function wtRevBtn(node, data, rev) {
|
|
1374
|
+
const deck = node.querySelector('#wtDeck'), rv = node.querySelector('#wtDrev'); if (!deck || !rv) return
|
|
1375
|
+
const slides = [...deck.children], rs = revNextState(wtDeckSteps(slides, data), Number(deck.dataset.cur) || 0, rev)
|
|
1376
|
+
rv.hidden = !rs.show
|
|
1377
|
+
const [l, sh] = revNextLabel(rs.last, rs.back)
|
|
1378
|
+
rv.setAttribute('aria-label', l); rv.dataset.tip = revNextTip(rs)
|
|
1379
|
+
const a1 = rv.querySelector('.wt-dl-l'), a2 = rv.querySelector('.wt-dl-s'); if (a1) a1.textContent = l; if (a2) a2.textContent = sh
|
|
1380
|
+
}
|
|
1351
1381
|
function wtMarks(s) {
|
|
1352
1382
|
const node = wtUi.node; if (!node || !s) return
|
|
1353
1383
|
const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
|
|
1354
|
-
const rev =
|
|
1355
|
-
for (const x of wtUi.pending) if (x.fp === data.fingerprint) { if (x.on) rev.add(x.step); else rev.delete(x.step) }
|
|
1384
|
+
const rev = wtRevNow(s, data)
|
|
1356
1385
|
for (const a of node.querySelectorAll('article.wt-st')) {
|
|
1357
1386
|
const on = rev.has(a.dataset.step)
|
|
1358
1387
|
a.classList.toggle('done', on)
|
|
@@ -1362,6 +1391,8 @@ function wtMarks(s) {
|
|
|
1362
1391
|
for (const d of node.querySelectorAll('.wt-dot[data-step]')) { const on = rev.has(d.dataset.step); if (d.classList.contains('done') !== on) { d.classList.toggle('done', on); d.setAttribute('aria-label', d.getAttribute('aria-label').replace(/, reviewed$/, '') + (on ? ', reviewed' : '')) } } // 0.9.18 a ✓ on its dot
|
|
1363
1392
|
for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
|
|
1364
1393
|
const p = node.querySelector('#wtProg'); if (p) p.textContent = (data.steps || []).filter((x) => rev.has(x.id)).length + ' of ' + (data.steps || []).length + ' reviewed'
|
|
1394
|
+
const all = node.querySelector('#wtAllDone'); if (all) all.hidden = !((data.steps || []).length && data.steps.every((x) => rev.has(x.id)))
|
|
1395
|
+
wtRevBtn(node, data, rev)
|
|
1365
1396
|
}
|
|
1366
1397
|
async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
1367
1398
|
const s = wtSession(sid)
|
|
@@ -1387,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
|
1387
1418
|
function wtRevNext() {
|
|
1388
1419
|
const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
|
|
1389
1420
|
const slides = [...deck.children], cur = wtDeckCur()
|
|
1390
|
-
const
|
|
1421
|
+
const sid = wtUi.sid, s = wtSession(sid), data = wtUi.data.get(wtKey(sid))
|
|
1422
|
+
if (!s || !data) return
|
|
1423
|
+
const rev = wtRevNow(s, data)
|
|
1424
|
+
const st = revNextState(wtDeckSteps(slides, data), cur, rev)
|
|
1391
1425
|
if (!st.show) return void wtSlideGo(cur + 1)
|
|
1392
|
-
const step = slides[cur].dataset.step
|
|
1393
|
-
|
|
1394
|
-
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1426
|
+
const step = slides[cur].dataset.step
|
|
1427
|
+
if (!rev.has(step)) wtReviewed(sid, step, { force: true, advance: false })
|
|
1395
1428
|
wtSlideGo(st.to)
|
|
1396
1429
|
}
|
|
1397
1430
|
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
@@ -1442,8 +1475,7 @@ function wtSlideGo(i) {
|
|
|
1442
1475
|
if (nav) {
|
|
1443
1476
|
nav.querySelector('.wt-dprev').disabled = i === 0
|
|
1444
1477
|
nav.querySelector('.wt-dnext').disabled = i === n - 1
|
|
1445
|
-
const
|
|
1446
|
-
if (rv) { rv.hidden = !rs.show; const [l, sh] = revNextLabel(rs.last); rv.setAttribute('aria-label', l); rv.dataset.tip = rs.last ? REVFINISH_TIP : REVNEXT_TIP; const a1 = rv.querySelector('.wt-dl-l'), a2 = rv.querySelector('.wt-dl-s'); if (a1) a1.textContent = l; if (a2) a2.textContent = sh }
|
|
1478
|
+
const s = wtSession(wtUi.sid); if (s && data.fingerprint) wtRevBtn(node, data, wtRevNow(s, data))
|
|
1447
1479
|
const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
|
|
1448
1480
|
const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
|
|
1449
1481
|
;[...nav.querySelectorAll('.wt-dot')].forEach((d, j) => { d.classList.toggle('cur', j === i); if (j === i) d.setAttribute('aria-current', 'step'); else d.removeAttribute('aria-current') })
|
|
@@ -1506,11 +1538,26 @@ function wtExport(kind) {
|
|
|
1506
1538
|
if (!s || !data || data.status !== 'ok') return
|
|
1507
1539
|
const title = s.title || 'Session'
|
|
1508
1540
|
if (kind === 'md') return copy(toMarkdown(data, { title })).then((ok) => toast(ok ? 'Copied as Markdown · paste it into the PR' : 'Couldn’t copy', 3000))
|
|
1509
|
-
|
|
1541
|
+
return saveHtmlFile(walkthroughExportHtml(data, { ...s, title }), exportName(title)) // 2026-11-06 the same file as leftoff export walkthrough
|
|
1542
|
+
}
|
|
1543
|
+
// 2026-11-06 Save as HTML (digest, decision log, walkthrough): one self-contained file. On a phone the
|
|
1544
|
+
// share sheet (Save to Files, AirDrop, Mail …) where it takes files; else a download. Nothing is uploaded.
|
|
1545
|
+
async function saveHtmlFile(html, name) {
|
|
1546
|
+
const coarse = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
|
1547
|
+
if (coarse && typeof File === 'function' && navigator.canShare && navigator.share) {
|
|
1548
|
+
try {
|
|
1549
|
+
const f = new File([html], name, { type: 'text/html' })
|
|
1550
|
+
if (navigator.canShare({ files: [f] })) { await navigator.share({ files: [f], title: name.replace(/\.html$/, '') }); return true }
|
|
1551
|
+
} catch (e) { if (e && e.name === 'AbortError') return false } // closed the sheet: nothing else
|
|
1552
|
+
}
|
|
1510
1553
|
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
|
1511
|
-
const
|
|
1554
|
+
const x = document.createElement('a'); x.href = url; x.download = name; document.body.appendChild(x); x.click(); x.remove()
|
|
1512
1555
|
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
1556
|
+
toast('Saved ' + name + ' · one file, opens offline', 3000)
|
|
1557
|
+
return true
|
|
1513
1558
|
}
|
|
1559
|
+
const SAVE_HTML_TIP = 'Save as HTML · one file with nothing linked out: opens offline, on a phone, and prints'
|
|
1560
|
+
const saveHtmlBtn = (action, cls = '') => '<button type="button" class="btn save-html' + (cls ? ' ' + cls : '') + '" data-action="' + action + '" data-tip="' + esc(SAVE_HTML_TIP) + '" aria-label="Save as HTML">' + icon('download') + '<span class="save-html-l">Save as HTML</span></button>'
|
|
1514
1561
|
// ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
|
|
1515
1562
|
// Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
|
|
1516
1563
|
// number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
|
|
@@ -1644,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
|
|
|
1644
1691
|
// Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
|
|
1645
1692
|
// (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
|
|
1646
1693
|
function wtSpots(data) { return data && data.status === 'ok' ? spotsFor(data, readingOrder(data.steps, data)) : [] }
|
|
1694
|
+
// 2026-11-06 from where you're reading (ui/walk-spots.js spotFrom): the slide on screen, or on the
|
|
1695
|
+
// long page the step you're at; -1 the overview, Infinity Safe to skip
|
|
1696
|
+
function wtSpotAt(data) {
|
|
1697
|
+
const order = readingOrder(data.steps, data).map((x) => x.id)
|
|
1698
|
+
const rank = (id) => order.indexOf(id)
|
|
1699
|
+
const deck = wtUi.node && wtUi.node.querySelector('#wtDeck')
|
|
1700
|
+
let at = -1
|
|
1701
|
+
if (deck) { const sl = deck.children[Number(deck.dataset.cur) || 0]; at = !sl || sl.dataset.slide === '0' ? -1 : sl.dataset.step ? rank(sl.dataset.step) : Infinity }
|
|
1702
|
+
else { const c = wtUi.cur.get(wtKey(wtUi.sid)); at = c ? rank(c) : -1 }
|
|
1703
|
+
return { rank, at }
|
|
1704
|
+
}
|
|
1647
1705
|
function wtSpotGo(dir = 1) {
|
|
1648
1706
|
const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
|
|
1649
1707
|
if (!data || data.status !== 'ok' || !wtUi.node) return
|
|
1650
1708
|
const spots = wtSpots(data)
|
|
1651
1709
|
if (!spots.length) return
|
|
1652
1710
|
const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
|
|
1653
|
-
const
|
|
1711
|
+
const { rank, at } = wtSpotAt(data)
|
|
1712
|
+
const i = spotFrom(spots, rank, at, cur, dir)
|
|
1654
1713
|
wtUi.spot.set(data.fingerprint, i)
|
|
1655
1714
|
const sp = spots[i]
|
|
1715
|
+
const row = wtReveal('s:' + sp.step + ':' + sp.snip, wtSpotRi(data, sp))
|
|
1716
|
+
wtSpotPaint()
|
|
1717
|
+
if (!row) return
|
|
1718
|
+
const n = wtUi.node.querySelector('#wtSpotN'); if (n) n.textContent = spotCountWords(spots.length, i)
|
|
1719
|
+
const b = wtUi.node.querySelector('.wt-spot'); if (b) b.setAttribute('aria-label', 'Next spot to look at, ' + spotCountWords(spots.length, i))
|
|
1720
|
+
}
|
|
1721
|
+
// a spot -> its row's index in its snippet's drawn diff
|
|
1722
|
+
function wtSpotRi(data, sp) {
|
|
1656
1723
|
const st = (data.steps || []).find((x) => x.id === sp.step)
|
|
1657
1724
|
const sn = st && st.snippets[sp.snip]
|
|
1658
1725
|
const rows = sn ? rowsOf(sn.patch) : []
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1726
|
+
return rows.findIndex((r) => (r.k === 'add' || r.k === 'del' || r.k === 'ctx') && (sp.side === 'old' ? r.o : r.n) === sp.line)
|
|
1727
|
+
}
|
|
1728
|
+
// 2026-11-06 (Joel: "it's the only time you see the special chip, that should probably be visible
|
|
1729
|
+
// all the time"): every spot's chip sits at its line whenever its diff is drawn, the one Next spot
|
|
1730
|
+
// is on marked; each chip explains itself in a tip (hover, focus, or a tap on a phone: ui/tips.js)
|
|
1731
|
+
function wtSpotPaint() {
|
|
1732
|
+
const node = wtUi.node, sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
|
|
1733
|
+
if (!node || !data || data.status !== 'ok' || wtUi.view !== 'story') return
|
|
1734
|
+
const spots = wtSpots(data)
|
|
1735
|
+
const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
|
|
1736
|
+
spots.forEach((sp, i) => {
|
|
1737
|
+
const gd = node.querySelector('.wt-gd[data-wtk="' + CSS.escape('s:' + sp.step + ':' + sp.snip) + '"]')
|
|
1738
|
+
if (!gd) return
|
|
1739
|
+
const ri = wtSpotRi(data, sp)
|
|
1740
|
+
const cands = [...gd.querySelectorAll('[data-ri="' + ri + '"]')]
|
|
1741
|
+
// in the split drawing a removed line is the left half, an added one the right
|
|
1742
|
+
const at = cands.find((x) => x.classList.contains(sp.side === 'new' ? 'gd-hr' : 'gd-hl')) || cands[0]
|
|
1743
|
+
if (!at) return
|
|
1744
|
+
let tag = at.querySelector(':scope > .wt-spot-tag')
|
|
1745
|
+
if (!tag) {
|
|
1746
|
+
const id = 'wt-spot-tip-' + i
|
|
1747
|
+
tag = document.createElement('span')
|
|
1748
|
+
tag.className = 'wt-spot-tag'
|
|
1749
|
+
tag.innerHTML = '<button type="button" class="tip-btn wt-spot-chip" aria-expanded="false" aria-controls="' + id + '" aria-describedby="' + id + '">' + icon('crosshair') + '<span></span></button><span class="tip" role="tooltip" id="' + id + '" hidden></span>'
|
|
1750
|
+
tag.querySelector('.wt-spot-chip span').textContent = sp.why || 'Worth a look'
|
|
1751
|
+
tag.querySelector('.wt-spot-chip').setAttribute('aria-label', 'Spot ' + (i + 1) + ' of ' + spots.length + ': ' + (sp.why || 'worth a look') + '. Why it is a spot')
|
|
1752
|
+
tag.querySelector('.tip').textContent = spotTip(sp)
|
|
1753
|
+
at.appendChild(tag)
|
|
1754
|
+
}
|
|
1755
|
+
const on = i === cur
|
|
1756
|
+
at.classList.toggle('wt-spot-on', on)
|
|
1757
|
+
tag.classList.toggle('cur', on)
|
|
1758
|
+
})
|
|
1678
1759
|
}
|
|
1679
1760
|
// Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
|
|
1680
1761
|
// only on a screen 1000px or wider, else unified (the toggle is hidden there)
|
|
@@ -1682,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
|
|
|
1682
1763
|
function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
|
|
1683
1764
|
function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
|
|
1684
1765
|
if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
|
|
1685
|
-
function wtHydrate(node, data) {
|
|
1766
|
+
function wtHydrate(node, data) { const r = hydrateWalkthrough(node, data, { renderDiff, renderSplit, split: wtSplitOn(), esc, icon }); if (node === wtUi.node) wtSpotPaint(); return r }
|
|
1686
1767
|
// the file list folds to a rail ([ or its button) and its edge drags; both kept per device
|
|
1687
1768
|
function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
|
|
1688
1769
|
function wtListUp() {
|
|
@@ -3542,7 +3623,16 @@ function setFullClass(on) {
|
|
|
3542
3623
|
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3543
3624
|
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3544
3625
|
// 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
|
|
3626
|
+
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
|
|
3627
|
+
// 2026-11-06 iOS 26's keyboard bar floats over the page (ui/viewport-fit.js ACCESSORY_PX): an iPhone or iPad
|
|
3628
|
+
// whose Safari has a CSS feature that arrived in 26 keeps room for it above the keyboard
|
|
3629
|
+
const KB_ACCESSORY = (() => {
|
|
3630
|
+
try {
|
|
3631
|
+
const ios = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
|
3632
|
+
const glass = !!(window.CSS && CSS.supports && (CSS.supports('anchor-name: --lf') || CSS.supports('animation-timeline: scroll()')))
|
|
3633
|
+
return keyboardAccessoryPx({ ios, glass })
|
|
3634
|
+
} catch { return 0 }
|
|
3635
|
+
})()
|
|
3546
3636
|
// 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
|
|
3547
3637
|
function fitSheetAside() {
|
|
3548
3638
|
const html = document.documentElement, a = document.activeElement
|
|
@@ -3555,16 +3645,17 @@ function fitFullViewport() {
|
|
|
3555
3645
|
const vv = window.visualViewport
|
|
3556
3646
|
const html = document.documentElement, st = html.style
|
|
3557
3647
|
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 }
|
|
3648
|
+
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
3649
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3560
3650
|
const strip = document.getElementById('tabStrip')
|
|
3561
3651
|
const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
|
|
3562
3652
|
st.setProperty('--lf-bar', bar + 'px')
|
|
3563
3653
|
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 }
|
|
3654
|
+
const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing, accessory: KB_ACCESSORY })
|
|
3655
|
+
if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-acc'); html.classList.remove('lf-kb'); return }
|
|
3566
3656
|
st.setProperty('--lf-h', fit.h + 'px')
|
|
3567
3657
|
st.setProperty('--lf-top', fit.top + 'px')
|
|
3658
|
+
st.setProperty('--lf-acc', (fit.pad || 0) + 'px') // room kept under the composer for a keyboard bar drawn over the page
|
|
3568
3659
|
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3569
3660
|
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3570
3661
|
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
@@ -3579,7 +3670,7 @@ function keepTypingInView() {
|
|
|
3579
3670
|
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3580
3671
|
const box = el.closest('.live-box.full')
|
|
3581
3672
|
if (!box) return
|
|
3582
|
-
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3673
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv, 1, parseFloat(html.style.getPropertyValue('--lf-acc')) || 0)
|
|
3583
3674
|
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3584
3675
|
}
|
|
3585
3676
|
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
@@ -3587,7 +3678,21 @@ function keepTypingInView() {
|
|
|
3587
3678
|
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3588
3679
|
function onViewportChange(ev) {
|
|
3589
3680
|
fitFullViewport()
|
|
3590
|
-
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
|
|
3681
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) { settleWhileTyping(1); followFrames() }
|
|
3682
|
+
}
|
|
3683
|
+
// 2026-11-06: and every frame for FOLLOW_FRAMES_MS after the box takes focus or iOS reports a change,
|
|
3684
|
+
// so the keyboard's animation (and iOS's numbers settling a frame or two late) is followed as it goes
|
|
3685
|
+
function followFrames() {
|
|
3686
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
3687
|
+
lfKb.until = performance.now() + FOLLOW_FRAMES_MS
|
|
3688
|
+
if (lfKb.raf) return
|
|
3689
|
+
const frame = () => {
|
|
3690
|
+
lfKb.raf = 0
|
|
3691
|
+
if (!isTypingTarget(document.activeElement) || !document.documentElement.classList.contains('live-full')) return
|
|
3692
|
+
fitFullViewport()
|
|
3693
|
+
if (performance.now() < lfKb.until) lfKb.raf = requestAnimationFrame(frame)
|
|
3694
|
+
}
|
|
3695
|
+
lfKb.raf = requestAnimationFrame(frame)
|
|
3591
3696
|
}
|
|
3592
3697
|
function settleWhileTyping(from = 0) {
|
|
3593
3698
|
clearTimeout(lfKb.settle)
|
|
@@ -3612,6 +3717,7 @@ document.addEventListener('focusin', (e) => {
|
|
|
3612
3717
|
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3613
3718
|
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3614
3719
|
settleWhileTyping(0)
|
|
3720
|
+
followFrames()
|
|
3615
3721
|
})
|
|
3616
3722
|
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3617
3723
|
// goes down, whatever iOS reports or doesn't on the way.
|
|
@@ -3740,7 +3846,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
|
|
|
3740
3846
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
3741
3847
|
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
3848
|
'<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>' +
|
|
3849
|
+
liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
|
|
3744
3850
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
3745
3851
|
(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
3852
|
(strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
|
|
@@ -4247,15 +4353,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
|
|
|
4247
4353
|
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
4248
4354
|
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
4249
4355
|
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
4250
|
-
async function refreshLive(reason) {
|
|
4356
|
+
async function refreshLive(reason, { force = false } = {}) {
|
|
4251
4357
|
const id = liveOnScreen()
|
|
4252
|
-
|
|
4358
|
+
// 2026-11-06: a fetch that was in flight when the phone froze the page may never answer; coming
|
|
4359
|
+
// back (force) doesn't wait behind it, and its late answer is dropped (the newer one wins)
|
|
4360
|
+
if (!id || (liveUi.polling && !(force && Date.now() - (liveUi.triedAt || 0) > 1500))) return false
|
|
4361
|
+
const gen = liveUi.gen = (liveUi.gen || 0) + 1
|
|
4253
4362
|
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
4254
4363
|
let v
|
|
4255
|
-
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
4364
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { if (gen === liveUi.gen) liveUi.polling = false }
|
|
4365
|
+
if (gen !== liveUi.gen) return false
|
|
4256
4366
|
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
4257
4367
|
const s = freshSession(state.sessions.find((x) => x.id === id))
|
|
4258
|
-
const sig = liveSignature(v.messages)
|
|
4368
|
+
const sig = liveSignature(v.messages) + '#' + subSig(v.subagents) // 2026-11-06: a subagent's step changes the thread's line
|
|
4259
4369
|
const before = liveUi.sig[id]
|
|
4260
4370
|
liveUi.sig[id] = sig
|
|
4261
4371
|
if (s && before !== sig) {
|
|
@@ -4265,6 +4375,8 @@ async function refreshLive(reason) {
|
|
|
4265
4375
|
const tmp = document.createElement('div')
|
|
4266
4376
|
tmp.innerHTML = liveBoxHtml(s, v)
|
|
4267
4377
|
const fresh = tmp.querySelector('.live-thread')
|
|
4378
|
+
const slot = box.querySelector('.lv-subs-slot'), freshSlot = tmp.querySelector('.lv-subs-slot') // 2026-11-06: subagents working, outside the kept thread
|
|
4379
|
+
if (slot && freshSlot) slot.innerHTML = freshSlot.innerHTML
|
|
4268
4380
|
if (fresh) {
|
|
4269
4381
|
const next = cur.nextSibling
|
|
4270
4382
|
liveKeeper.before(box)
|
|
@@ -4284,8 +4396,13 @@ async function refreshLive(reason) {
|
|
|
4284
4396
|
// The safety net, and the stream's watchdog, on one slow timer.
|
|
4285
4397
|
const streamDog = createWatchdog()
|
|
4286
4398
|
const wakeGate = createWake()
|
|
4399
|
+
let tickAt = Date.now() // 2026-11-06: the timer's own clock, to notice a frozen page (live-wake.js resumedAfter)
|
|
4287
4400
|
setInterval(() => {
|
|
4401
|
+
const was = tickAt; tickAt = Date.now()
|
|
4288
4402
|
if (document.visibilityState === 'hidden') return
|
|
4403
|
+
const frozen = resumedAfter(was, tickAt)
|
|
4404
|
+
if (frozen) onWake({ type: 'resume', away: frozen })
|
|
4405
|
+
if (renderDue && !busy()) { renderDue = false; render() } // a redraw a wake-up couldn't make while you typed
|
|
4289
4406
|
const id = liveOnScreen()
|
|
4290
4407
|
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
4408
|
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
@@ -4293,27 +4410,66 @@ setInterval(() => {
|
|
|
4293
4410
|
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
4294
4411
|
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
4295
4412
|
// open a new stream if the old one missed its pings.
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4413
|
+
let renderDue = false // 2026-11-06: a wake-up's redraw held back while you type (busy()); made once you stop
|
|
4414
|
+
let freshAt = Date.now() // when the view's data was last known current (a refetch that answered)
|
|
4415
|
+
const WAKES = new Set(['visible', 'focus', 'online', 'pageshow-bfcache', 'resume', 'reconnect', 'resync', 'relay-switch'])
|
|
4416
|
+
async function refreshView(reason) {
|
|
4417
|
+
const editing = state.route && ['settings', 'setup'].includes(state.route.name)
|
|
4418
|
+
const id = liveOnScreen()
|
|
4419
|
+
const before = viewSignature(state.sessions, id ? liveUi.sig[id] : '')
|
|
4420
|
+
const wake = WAKES.has(reason)
|
|
4421
|
+
if (reason === 'reconnect' && Date.now() - wakeAt < 2500) return // the stream a wake-up just opened: that wake-up is reading the view
|
|
4422
|
+
const jobs = [id ? refreshLive(reason, { force: wake }) : null, refreshHealth()]
|
|
4423
|
+
if (editing) { /* Settings reads its own sections */ } else if (busy()) renderDue = true
|
|
4424
|
+
else jobs.push(render())
|
|
4425
|
+
await Promise.all(jobs.map((p) => Promise.resolve(p).catch(() => null)))
|
|
4426
|
+
if (!wake) return
|
|
4427
|
+
freshAt = Date.now()
|
|
4428
|
+
const id2 = liveOnScreen()
|
|
4429
|
+
if (viewSignature(state.sessions, id2 ? liveUi.sig[id2] : '') !== before) caughtUpCue()
|
|
4430
|
+
}
|
|
4431
|
+
// The quiet "Updated" a wake-up's refetch shows when it brought something new: a word and a check
|
|
4432
|
+
// (never color alone), no spinner, gone in a moment; still under reduced motion (app.css).
|
|
4433
|
+
function caughtUpCue() {
|
|
4434
|
+
let el = document.getElementById('caughtUp')
|
|
4435
|
+
if (!el) {
|
|
4436
|
+
el = document.createElement('div')
|
|
4437
|
+
el.id = 'caughtUp'; el.className = 'caught-up'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite')
|
|
4438
|
+
el.innerHTML = icon('check') + '<span>Updated</span>'
|
|
4439
|
+
document.body.appendChild(el)
|
|
4440
|
+
}
|
|
4441
|
+
el.hidden = false; el.classList.remove('out'); void el.offsetWidth; el.classList.add('in')
|
|
4442
|
+
clearTimeout(caughtUpCue.t)
|
|
4443
|
+
caughtUpCue.t = setTimeout(() => { el.classList.remove('in'); el.classList.add('out'); caughtUpCue.t = setTimeout(() => { el.hidden = true }, 400) }, 1600)
|
|
4300
4444
|
}
|
|
4301
4445
|
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
4302
4446
|
function onWake(ev) {
|
|
4303
4447
|
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 })
|
|
4448
|
+
const why = ev.type === 'resume' ? 'resume' : wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
4305
4449
|
if (!why) return
|
|
4306
4450
|
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
4307
|
-
const
|
|
4308
|
-
const
|
|
4451
|
+
const now = Date.now()
|
|
4452
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, now, HEARTBEAT_MS + 2000)
|
|
4453
|
+
const away = Math.max(awayAt ? now - awayAt : 0, ev.away || 0)
|
|
4309
4454
|
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 (
|
|
4455
|
+
// a click back into the window a moment later isn't a wake-up; coming back after a while, a
|
|
4456
|
+
// restore, the network or a frozen page is (live-wake.js catchUp)
|
|
4457
|
+
const plan = catchUp({ why, away, dead, freshAt: Math.max(freshAt, streamDog.lastAt || 0), now })
|
|
4458
|
+
if (!plan.refetch) return
|
|
4459
|
+
// the timer's freeze and the page's own events arrive together: one catch-up
|
|
4460
|
+
if (now - wakeAt < 2500 && !dead) return
|
|
4461
|
+
if (!wakeGate.due() && !plan.reconnect) return
|
|
4462
|
+
wakeAt = now
|
|
4463
|
+
if (plan.reconnect && now - (connect.at || 0) > 2000) connect()
|
|
4314
4464
|
refreshView(why)
|
|
4315
4465
|
}
|
|
4466
|
+
let wakeAt = 0
|
|
4316
4467
|
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
4468
|
+
// 2026-11-06: the relay moved this board to another Mac: a new stream from it, and its data
|
|
4469
|
+
window.addEventListener('leftoff-relay', (e) => {
|
|
4470
|
+
if (!(e.detail && e.detail.switched)) return
|
|
4471
|
+
clearTimeout(onWake.sw); onWake.sw = setTimeout(() => { connect(); refreshView('relay-switch') }, 300)
|
|
4472
|
+
})
|
|
4317
4473
|
// 0.9.15 (ui/away.js): when this board was last on screen here
|
|
4318
4474
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
|
|
4319
4475
|
window.addEventListener('pagehide', () => presence.here())
|
|
@@ -4919,6 +5075,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4919
5075
|
if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
|
|
4920
5076
|
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4921
5077
|
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
5078
|
+
if (a === 'dec-save-html') { const v = decisionsUi.view; if (v) saveHtmlFile(decisionsExportHtml(v, { all: !!decisionsUi.all }), exportFileName('decisions', (v.scope && (v.scope.name || v.scope.label)) || '')); return } // 2026-11-06
|
|
4922
5079
|
if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
|
|
4923
5080
|
if (a === 'dec-show') {
|
|
4924
5081
|
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
@@ -5179,9 +5336,6 @@ async function viewTimeline() {
|
|
|
5179
5336
|
$app.innerHTML = html
|
|
5180
5337
|
}
|
|
5181
5338
|
|
|
5182
|
-
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
5183
|
-
// links there and Start here gets that session's Go / Resume.
|
|
5184
|
-
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
5185
5339
|
function briefAction(id, sessions, viewer) {
|
|
5186
5340
|
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
5187
5341
|
const s = sessions && sessions.get(id)
|
|
@@ -5193,75 +5347,10 @@ function briefAction(id, sessions, viewer) {
|
|
|
5193
5347
|
return b || open
|
|
5194
5348
|
}
|
|
5195
5349
|
|
|
5350
|
+
// 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
|
|
5196
5351
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
5200
|
-
const out = []
|
|
5201
|
-
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
5202
|
-
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
5203
|
-
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
5204
|
-
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
5205
|
-
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
5206
|
-
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
5207
|
-
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
5208
|
-
const openSec = (h) => {
|
|
5209
|
-
if (inSec) out.push('</section>')
|
|
5210
|
-
inSec = true
|
|
5211
|
-
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
5212
|
-
else if (spaces.includes(h)) {
|
|
5213
|
-
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
5214
|
-
'<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
|
|
5215
|
-
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
5216
|
-
}
|
|
5217
|
-
for (const raw of body.split('\n')) {
|
|
5218
|
-
const mid = raw.match(MD_SID)
|
|
5219
|
-
const id = mid ? mid[1] : null
|
|
5220
|
-
const line = mid ? raw.slice(0, mid.index) : raw
|
|
5221
|
-
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
5222
|
-
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
5223
|
-
if (st) {
|
|
5224
|
-
openList('ol', 'start')
|
|
5225
|
-
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
5226
|
-
out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
|
|
5227
|
-
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
5228
|
-
continue
|
|
5229
|
-
}
|
|
5230
|
-
if (/^- /.test(line)) {
|
|
5231
|
-
openList('ul')
|
|
5232
|
-
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
5233
|
-
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
5234
|
-
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
5235
|
-
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
5236
|
-
// session: the title links to it, the sentence under it, the tags quiet.
|
|
5237
|
-
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
5238
|
-
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
5239
|
-
if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div><div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>'); continue }
|
|
5240
|
-
if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
|
|
5241
|
-
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
5242
|
-
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
5243
|
-
out.push('<li' + attrs + '>' + li); continue
|
|
5244
|
-
}
|
|
5245
|
-
if (/^\s{2,}\S/.test(line) && inList) {
|
|
5246
|
-
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
5247
|
-
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
5248
|
-
if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
|
|
5249
|
-
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
5250
|
-
continue
|
|
5251
|
-
}
|
|
5252
|
-
closeList()
|
|
5253
|
-
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
5254
|
-
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
5255
|
-
else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
|
|
5256
|
-
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
5257
|
-
else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
|
|
5258
|
-
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
5259
|
-
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
5260
|
-
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
5261
|
-
}
|
|
5262
|
-
closeList()
|
|
5263
|
-
if (inSec) out.push('</section>')
|
|
5264
|
-
return out.join('\n')
|
|
5352
|
+
return digestBodyHtml(md, { spaces, esc, action: (id) => briefAction(id, sessions, viewer),
|
|
5353
|
+
standup: (h) => '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button>' })
|
|
5265
5354
|
}
|
|
5266
5355
|
|
|
5267
5356
|
// "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
|
|
@@ -5370,6 +5459,7 @@ function repaintCalendar() {
|
|
|
5370
5459
|
// repo chips, which only count the picked space.
|
|
5371
5460
|
const digestUi = { standups: {}, date: null, open: null }
|
|
5372
5461
|
async function viewDigest(date, machine) {
|
|
5462
|
+
digestUi.md = null
|
|
5373
5463
|
const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
|
|
5374
5464
|
const byDay = new Map()
|
|
5375
5465
|
for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
|
|
@@ -5398,9 +5488,12 @@ async function viewDigest(date, machine) {
|
|
|
5398
5488
|
if (pick) {
|
|
5399
5489
|
try {
|
|
5400
5490
|
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
5401
|
-
const
|
|
5491
|
+
const ver = ((state.route && state.route.params) || new URLSearchParams()).get('v') // 2026-11-06: an earlier version (ui/digest-versions.js)
|
|
5492
|
+
const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ver ? '?version=' + encodeURIComponent(ver) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
|
|
5493
|
+
digestUi.shown = d
|
|
5402
5494
|
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
5403
5495
|
const fm = d.meta || {}
|
|
5496
|
+
digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
|
|
5404
5497
|
const madeAt = fm.generated_at
|
|
5405
5498
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
5406
5499
|
const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
|
|
@@ -5408,12 +5501,13 @@ async function viewDigest(date, machine) {
|
|
|
5408
5501
|
const others = (d.others || []).filter((x) => x.machine || x.shared)
|
|
5409
5502
|
const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
|
|
5410
5503
|
const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
|
|
5411
|
-
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
5504
|
+
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + '<div class="digest-save">' + saveHtmlBtn('digest-save-html') + '</div>' + also + unnamed +
|
|
5412
5505
|
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
|
|
5413
5506
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
5414
5507
|
} else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
|
|
5415
5508
|
else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
|
|
5416
5509
|
$app.innerHTML = html + '</div>'
|
|
5510
|
+
if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
|
|
5417
5511
|
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
5418
5512
|
const art = $app.querySelector('article.md')
|
|
5419
5513
|
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
@@ -5439,7 +5533,7 @@ function paintDigestFilter() {
|
|
|
5439
5533
|
const spaces = state.digestSpaces || []
|
|
5440
5534
|
const sp = state.digestSpace
|
|
5441
5535
|
const secs = [...document.querySelectorAll('.md section')]
|
|
5442
|
-
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
5536
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !sec.classList.contains('done-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
5443
5537
|
const tabsBox = document.getElementById('digestSpaces')
|
|
5444
5538
|
if (tabsBox) {
|
|
5445
5539
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -6323,7 +6417,7 @@ async function viewSettings() {
|
|
|
6323
6417
|
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
6324
6418
|
'<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
|
|
6325
6419
|
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
|
|
6326
|
-
digest: digestSettings(c),
|
|
6420
|
+
digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
|
|
6327
6421
|
spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
|
|
6328
6422
|
sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
|
|
6329
6423
|
agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
|
|
@@ -6370,6 +6464,7 @@ async function viewSettings() {
|
|
|
6370
6464
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
6371
6465
|
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
6372
6466
|
showSettingsSection(r)
|
|
6467
|
+
api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
|
|
6373
6468
|
approvedUi.info = starts; approvedUi.err = ''
|
|
6374
6469
|
fillApproved($app, starts) // approved folders: paths as text
|
|
6375
6470
|
const ap = document.getElementById('f-start-approve')
|
|
@@ -8463,7 +8558,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
8463
8558
|
tip.classList.add('on')
|
|
8464
8559
|
}, delay)
|
|
8465
8560
|
}
|
|
8466
|
-
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
8561
|
+
const tipped = (e) => e.target.closest && e.target.closest('.save-html[data-tip], #tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
8467
8562
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
8468
8563
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
8469
8564
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
@@ -8605,14 +8700,14 @@ function keepPlace() {
|
|
|
8605
8700
|
document.addEventListener('touchstart', (e) => {
|
|
8606
8701
|
if (refreshing || e.touches.length !== 1) { pull = null; return }
|
|
8607
8702
|
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
|
|
8703
|
+
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
8704
|
}, { passive: true })
|
|
8610
8705
|
document.addEventListener('touchmove', (e) => {
|
|
8611
8706
|
if (!pull || e.touches.length !== 1) return
|
|
8612
8707
|
const t = e.touches[0]
|
|
8613
8708
|
const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
|
|
8614
8709
|
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 }
|
|
8710
|
+
if (!pull.head && (window.scrollY || 0) > 0) { pull = null; paint(null); return } // a pull from the bars at the top works scrolled anywhere
|
|
8616
8711
|
if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
|
|
8617
8712
|
pull.step = step
|
|
8618
8713
|
paint(step)
|
|
@@ -9173,6 +9268,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9173
9268
|
if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
|
|
9174
9269
|
if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
|
|
9175
9270
|
if (action === 'wt-export-html') return wtExport('html')
|
|
9271
|
+
if (action === 'digest-save-html') { if (digestUi.md) saveHtmlFile(digestExportHtml(digestUi.md, { date: digestUi.mdDate }), exportFileName('digest', digestUi.mdDate || '')); return } // 2026-11-06
|
|
9176
9272
|
if (action === 'wt-export-md') return wtExport('md')
|
|
9177
9273
|
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
9178
9274
|
if (action === 'copy-fact') {
|
|
@@ -9186,8 +9282,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
9186
9282
|
if (action === 'copy-cmd') {
|
|
9187
9283
|
const ok = await copy(btn.dataset.cmd)
|
|
9188
9284
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
9285
|
+
if (!btn._label) btn._label = btn.getAttribute('aria-label') || 'Copy' // 2026-11-06: each button keeps its own label (Live resume, the MCP setup lines)
|
|
9189
9286
|
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
9190
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label',
|
|
9287
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', btn._label); btn.dataset.tip = 'Copy' }, 1500)
|
|
9191
9288
|
}
|
|
9192
9289
|
return
|
|
9193
9290
|
}
|
|
@@ -9807,6 +9904,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9807
9904
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
9808
9905
|
if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
|
|
9809
9906
|
if (action === 'doc-close') return closeDoc()
|
|
9907
|
+
if (action === 'hp-mode' || action === 'hp-show' || action === 'hp-allow' || action === 'hp-block' || action === 'hp-reload') return hpAction(action, btn)
|
|
9810
9908
|
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
9811
9909
|
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
9812
9910
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
@@ -10105,7 +10203,7 @@ async function todoDoneChange(cb) {
|
|
|
10105
10203
|
|
|
10106
10204
|
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
10107
10205
|
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
10108
|
-
// rendered from escaped text; HTML files
|
|
10206
|
+
// rendered from escaped text; HTML files open on Preview (sandboxed) or Code; anything else downloads.
|
|
10109
10207
|
const docUi = { r: null, name: '', printing: null }
|
|
10110
10208
|
function docViewerEl() {
|
|
10111
10209
|
let el = document.getElementById('docViewer')
|
|
@@ -10125,7 +10223,88 @@ function docViewerEl() {
|
|
|
10125
10223
|
document.body.appendChild(el)
|
|
10126
10224
|
return el
|
|
10127
10225
|
}
|
|
10128
|
-
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
10226
|
+
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; el.querySelector('.dv-body').classList.remove('dv-hpv'); hpUi.r = null; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
10227
|
+
function docBodyLoading(el) { const b = el.querySelector('.dv-body'); b.classList.remove('dv-hpv'); hpUi.r = null; b.innerHTML = '<div class="empty">Opening…</div>' }
|
|
10228
|
+
// 2026-11-06 An HTML file: Preview (ui/html-preview.js, a sandboxed frame; remote
|
|
10229
|
+
// resources blocked until Allow, for this view only) or Code, remembered per device.
|
|
10230
|
+
const hpUi = { r: null, code: '', mode: 'preview', remote: false, open: false, refs: [] }
|
|
10231
|
+
function hpViewPref() { try { return localStorage.getItem(HP_VIEW_KEY) === 'code' ? 'code' : 'preview' } catch { return 'preview' } }
|
|
10232
|
+
function mountHtmlPreview(body, r, code, { line = null } = {}) {
|
|
10233
|
+
if (!isHtmlDoc(r)) return false
|
|
10234
|
+
Object.assign(hpUi, { r, code, mode: line ? 'code' : hpViewPref(), remote: false, open: false, refs: remoteRefs(r.text) })
|
|
10235
|
+
body.innerHTML = '<div class="hp-bar"></div><div class="hp-main"></div>'
|
|
10236
|
+
paintHpBar(body); paintHpMain(body)
|
|
10237
|
+
return true
|
|
10238
|
+
}
|
|
10239
|
+
function paintHpBar(body) { const bar = body.querySelector('.hp-bar'); if (bar) bar.innerHTML = previewBarHtml({ ...hpUi, icon }) }
|
|
10240
|
+
function paintHpMain(body) {
|
|
10241
|
+
const main = body.querySelector('.hp-main')
|
|
10242
|
+
if (!main || !hpUi.r) return
|
|
10243
|
+
const pv = hpUi.mode === 'preview'
|
|
10244
|
+
body.classList.toggle('dv-hpv', pv)
|
|
10245
|
+
if (!pv) { main.innerHTML = hpUi.code; return }
|
|
10246
|
+
main.textContent = ''
|
|
10247
|
+
const f = document.createElement('iframe')
|
|
10248
|
+
f.className = 'hp-frame'
|
|
10249
|
+
f.setAttribute('sandbox', 'allow-scripts')
|
|
10250
|
+
f.setAttribute('referrerpolicy', 'no-referrer')
|
|
10251
|
+
f.setAttribute('aria-label', 'Preview of ' + (hpUi.r.name || 'the page'))
|
|
10252
|
+
f._hpDoc = previewDoc(hpUi.r.text, { remote: hpUi.remote })
|
|
10253
|
+
f._hpPing = 0
|
|
10254
|
+
// once the page is in, every load is checked: only the page we wrote answers the ping
|
|
10255
|
+
// (ui/html-preview.js GUARD), so silence means it went somewhere else
|
|
10256
|
+
f.addEventListener('load', () => {
|
|
10257
|
+
if (f._hpDoc != null || !f.isConnected || !f.contentWindow) return
|
|
10258
|
+
const n = ++f._hpPing
|
|
10259
|
+
f.contentWindow.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10260
|
+
clearTimeout(f._hpWait)
|
|
10261
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1000)
|
|
10262
|
+
})
|
|
10263
|
+
f.src = PREVIEW_PAGE + (hpUi.remote ? '?remote=1' : '')
|
|
10264
|
+
main.appendChild(f)
|
|
10265
|
+
}
|
|
10266
|
+
// The page sent itself somewhere (CSP can't stop that): the frame goes, and the bar says so.
|
|
10267
|
+
function hpStopped(f) {
|
|
10268
|
+
if (!f || !f.isConnected) return
|
|
10269
|
+
clearTimeout(f._hpWait)
|
|
10270
|
+
const main = f.closest('.hp-main')
|
|
10271
|
+
f.remove()
|
|
10272
|
+
if (main) main.innerHTML = '<div class="empty fv-state hp-stopped"><b>Preview stopped</b><span>The page tried to open another address, so it was closed. Code shows what it does.</span><button type="button" class="btn sm" data-action="hp-reload">Load it again</button></div>'
|
|
10273
|
+
}
|
|
10274
|
+
function hpAction(action, btn) {
|
|
10275
|
+
const body = document.querySelector('#docViewer .dv-body')
|
|
10276
|
+
if (!body || !hpUi.r) return
|
|
10277
|
+
if (action === 'hp-mode') {
|
|
10278
|
+
const m = btn.dataset.mode === 'code' ? 'code' : 'preview'
|
|
10279
|
+
if (m === hpUi.mode) return
|
|
10280
|
+
hpUi.mode = m
|
|
10281
|
+
try { localStorage.setItem(HP_VIEW_KEY, m) } catch {}
|
|
10282
|
+
paintHpBar(body); paintHpMain(body); body.scrollTop = 0
|
|
10283
|
+
} else if (action === 'hp-show') { hpUi.open = !hpUi.open; paintHpBar(body) }
|
|
10284
|
+
else if (action === 'hp-reload') paintHpMain(body)
|
|
10285
|
+
else if (action === 'hp-allow' || action === 'hp-block') { hpUi.remote = action === 'hp-allow'; paintHpBar(body); paintHpMain(body) }
|
|
10286
|
+
const again = body.querySelector('[data-action="' + action + '"]') || body.querySelector('[data-action="' + (action === 'hp-allow' ? 'hp-block' : action === 'hp-block' ? 'hp-allow' : action) + '"]')
|
|
10287
|
+
if (again && action !== 'hp-mode') again.focus({ preventScroll: true })
|
|
10288
|
+
}
|
|
10289
|
+
// The sandbox asks for its page once (a frame that navigated away gets nothing), says where a
|
|
10290
|
+
// clicked link goes (it doesn't open), and passes Esc on, since keys in the frame stay there.
|
|
10291
|
+
window.addEventListener('message', (ev) => {
|
|
10292
|
+
const d = ev.data
|
|
10293
|
+
if (!d || typeof d !== 'object' || typeof d.t !== 'string' || !d.t.startsWith('lo-hp-')) return
|
|
10294
|
+
const f = [...document.querySelectorAll('#docViewer .hp-frame')].find((x) => x.contentWindow === ev.source)
|
|
10295
|
+
if (!f) return
|
|
10296
|
+
if (d.t === 'lo-hp-ready' && f._hpDoc != null) {
|
|
10297
|
+
const html = f._hpDoc; f._hpDoc = null
|
|
10298
|
+
ev.source.postMessage({ t: 'lo-hp-html', html }, '*')
|
|
10299
|
+
const n = ++f._hpPing // the write itself may not fire a load: check once now too
|
|
10300
|
+
ev.source.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10301
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1500)
|
|
10302
|
+
}
|
|
10303
|
+
else if (d.t === 'lo-hp-pong') { if (d.n === f._hpPing) { f._hpPong = d.n; clearTimeout(f._hpWait) } }
|
|
10304
|
+
else if (d.t === 'lo-hp-left') hpStopped(f)
|
|
10305
|
+
else if (d.t === 'lo-hp-link' && typeof d.href === 'string') toast('Links don’t open from a preview: ' + d.href.slice(0, 160), 5000)
|
|
10306
|
+
else if (d.t === 'lo-hp-esc') closeDoc()
|
|
10307
|
+
})
|
|
10129
10308
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
10130
10309
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
10131
10310
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -10134,7 +10313,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
10134
10313
|
const name = plan ? 'Plan' : String(path).split('/').pop()
|
|
10135
10314
|
el.querySelector('.dv-title').textContent = name
|
|
10136
10315
|
el.querySelector('.dv-sub').textContent = ''
|
|
10137
|
-
el
|
|
10316
|
+
docBodyLoading(el)
|
|
10138
10317
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10139
10318
|
docUi.r = null; docUi.name = name
|
|
10140
10319
|
el.hidden = false
|
|
@@ -10146,6 +10325,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
10146
10325
|
el.querySelector('.dv-title').textContent = r.name || name
|
|
10147
10326
|
el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
10148
10327
|
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
10328
|
+
mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
|
|
10149
10329
|
el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
|
|
10150
10330
|
docUi.r = r; docUi.name = name
|
|
10151
10331
|
const items = docMenuItems(r)
|
|
@@ -10170,7 +10350,7 @@ async function openToolOutput(sessionId, toolId, label) {
|
|
|
10170
10350
|
const name = label || 'Tool output'
|
|
10171
10351
|
el.querySelector('.dv-title').textContent = name
|
|
10172
10352
|
el.querySelector('.dv-sub').textContent = ''
|
|
10173
|
-
el
|
|
10353
|
+
docBodyLoading(el)
|
|
10174
10354
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10175
10355
|
docUi.r = null; docUi.name = name
|
|
10176
10356
|
el.hidden = false
|
|
@@ -10251,7 +10431,7 @@ async function loadRefFile() {
|
|
|
10251
10431
|
el.querySelector('.dv-title').textContent = name
|
|
10252
10432
|
el.querySelector('.dv-sub').textContent = ''
|
|
10253
10433
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
|
|
10254
|
-
el
|
|
10434
|
+
docBodyLoading(el)
|
|
10255
10435
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10256
10436
|
docUi.r = null; docUi.name = name; refUi.r = null
|
|
10257
10437
|
const wasHidden = el.hidden
|
|
@@ -10267,7 +10447,9 @@ async function loadRefFile() {
|
|
|
10267
10447
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
|
|
10268
10448
|
el.querySelector('.dv-sub').textContent = [r.size ? refSize(r.size) : '', r.lang ? r.lang.toUpperCase() : '', r.peer ? 'on ' + r.peer : '', refUi.line && (r.type === 'code' || r.type === 'text') ? 'line ' + refUi.line : ''].filter(Boolean).join(' · ')
|
|
10269
10449
|
const body = el.querySelector('.dv-body')
|
|
10270
|
-
|
|
10450
|
+
const code = refBody(r, { line: refUi.line })
|
|
10451
|
+
body.innerHTML = code
|
|
10452
|
+
mountHtmlPreview(body, r, code, { line: refUi.line })
|
|
10271
10453
|
el.querySelector('.dv-ptitle').textContent = r.name || name
|
|
10272
10454
|
if (r.type === 'pdf' && r.base64) {
|
|
10273
10455
|
const b64 = r.base64
|
|
@@ -10502,6 +10684,7 @@ let stream = null
|
|
|
10502
10684
|
function connect() {
|
|
10503
10685
|
if (stream) { try { stream.close() } catch {} }
|
|
10504
10686
|
const es = stream = new EventSource('/v1/stream')
|
|
10687
|
+
connect.at = Date.now()
|
|
10505
10688
|
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
10506
10689
|
streamDog.opened()
|
|
10507
10690
|
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
@@ -10512,6 +10695,9 @@ function connect() {
|
|
|
10512
10695
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
10513
10696
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
10514
10697
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
10698
|
+
// 2026-11-06: the relay's poll lost events (its Mac restarted, another Mac took over, or more
|
|
10699
|
+
// happened than the daemon keeps): read the view again (ui/relay-client.js)
|
|
10700
|
+
es.addEventListener('resync', () => { if (stream === es) refreshView('resync') })
|
|
10515
10701
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
10516
10702
|
// port; if the browser gave up on the stream, open a new one.
|
|
10517
10703
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|