@leftoff/cli 0.9.18-beta.8 → 0.9.18
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 +29 -2
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +792 -677
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +259 -17
- package/mod/hooks/register.js +20 -6
- package/package.json +1 -1
- package/ui/app.css +204 -5
- package/ui/app.js +655 -198
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +42 -0
- package/ui/decisions.js +81 -21
- package/ui/digest-versions.js +57 -0
- package/ui/fs-side.js +29 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +136 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +36 -5
- package/ui/live-wake.js +47 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/prs.js +28 -10
- package/ui/pull.js +30 -3
- 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 +58 -27
- package/ui/session-title.js +88 -0
- package/ui/settings-nav.js +1 -1
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/subagents-view.js +41 -0
- package/ui/summary-examples.js +1 -1
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +48 -3
- 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, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
|
|
7
|
+
import { fullViewportFit, isTypingTarget, outOfView, sheetAside, 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
|
|
@@ -12,9 +14,9 @@ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
|
|
|
12
14
|
import { changedKeys } from './settings-dirty.js'
|
|
13
15
|
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
|
-
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
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…
|
|
@@ -43,27 +46,30 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
43
46
|
import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
|
|
44
47
|
import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
|
|
45
48
|
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'
|
|
49
|
+
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
50
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
48
51
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
49
52
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
50
|
-
import {
|
|
53
|
+
import { cutTitle } from './session-title.js'
|
|
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
|
|
51
55
|
import { statusView, elapsedText } from './live-status.js'
|
|
52
|
-
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
|
|
53
57
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
54
58
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
55
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'
|
|
56
|
-
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
60
|
+
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
|
|
57
61
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
58
62
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
59
|
-
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
|
|
60
64
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
61
|
-
import { spotsFor,
|
|
65
|
+
import { spotsFor, spotFrom, spotTip, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
62
66
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
63
67
|
import { lastReply, ghostShows } from './ghost.js'
|
|
68
|
+
import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
|
|
64
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
|
|
65
70
|
import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
|
|
66
71
|
import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
|
|
72
|
+
import { isHtmlDoc, remoteRefs, localRefs, previewDoc, previewBarHtml, PREVIEW_PAGE, VIEW_KEY as HP_VIEW_KEY } from './html-preview.js'
|
|
67
73
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
68
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
|
|
69
75
|
import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
|
|
@@ -76,7 +82,10 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
|
|
|
76
82
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
77
83
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
78
84
|
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
79
|
-
import {
|
|
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)
|
|
88
|
+
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
80
89
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
81
90
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
82
91
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -465,7 +474,7 @@ function copiedMark(btn) {
|
|
|
465
474
|
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
466
475
|
}
|
|
467
476
|
// 0.9.13: 'background' reads "2 tasks running", its tip naming them (ui/bgtasks.js)
|
|
468
|
-
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>'
|
|
469
478
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
470
479
|
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
471
480
|
const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an asleep Mac's channel isn't listening
|
|
@@ -499,8 +508,11 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
499
508
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
500
509
|
const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
501
510
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
502
|
-
|
|
503
|
-
|
|
511
|
+
// decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
|
|
512
|
+
const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
|
|
513
|
+
const decidedSecHtml = (cp, log) => '<div class="cpx-sec cpx-decided" data-cp="' + esc(cp.id || '') + '"><div class="cpx-h">Decided</div>' + decidedListHtml(cp.decided, { esc, log, readOnly: true }) + '</div>'
|
|
514
|
+
function cpExtras(cp, paths = null, decided = null) {
|
|
515
|
+
let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
|
|
504
516
|
for (const [k, title] of EXTRA_TITLES) {
|
|
505
517
|
const v = cp[k]
|
|
506
518
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
@@ -519,11 +531,11 @@ function cpExtras(cp, paths = null) {
|
|
|
519
531
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
520
532
|
// part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
|
|
521
533
|
// paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
|
|
522
|
-
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
|
|
534
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
|
|
523
535
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
524
536
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
525
537
|
const you = /^YOU:/i.test(cp.next || '')
|
|
526
|
-
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
538
|
+
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
|
|
527
539
|
const dd = (t, cls = '') => {
|
|
528
540
|
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
529
541
|
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
@@ -533,16 +545,20 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
533
545
|
}
|
|
534
546
|
// 0.9.15: what the session is for and how far along, above its latest step
|
|
535
547
|
const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
|
|
536
|
-
|
|
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>'
|
|
537
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>' : '')
|
|
538
553
|
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
539
554
|
(goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
|
|
540
555
|
(rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
|
|
556
|
+
(rows ? '' : doneRow) +
|
|
541
557
|
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
542
558
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
543
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>' : '') +
|
|
544
560
|
'</dl>' +
|
|
545
|
-
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp
|
|
561
|
+
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + esc(cpFoldSummary(cp, { decided: decidedHere(cp, decided) })) + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths, decided) + '</div></details>' : '')
|
|
546
562
|
}
|
|
547
563
|
|
|
548
564
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -1131,8 +1147,27 @@ function card(s) {
|
|
|
1131
1147
|
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1132
1148
|
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1133
1149
|
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1134
|
-
|
|
1135
|
-
function
|
|
1150
|
+
// 2026-11-06 a PR range has no This session · Whole folder: it is the PR's own commits
|
|
1151
|
+
function wtBody(sid, extra = {}) { const sc = wtUi.scope.get(sid) || 'session'; return { scope: sc, ...(fileScope.get() === 'folder' && !isPrKey(sc) ? { files: 'folder' } : {}), ...extra } }
|
|
1152
|
+
function wtKey(sid) { const sc = wtUi.scope.get(sid) || 'session'; return sid + '|' + sc + (fileScope.get() === 'folder' && !isPrKey(sc) ? '|folder' : '') }
|
|
1153
|
+
// the ranges the daemon last listed for a session (any range's answer carries them all)
|
|
1154
|
+
function wtScopesOf(sid) { for (const [k, d] of wtUi.data) if (k.startsWith(sid + '|') && d && Array.isArray(d.scopes)) return d.scopes; return [] }
|
|
1155
|
+
function openWtPrMenu(btn, sid) {
|
|
1156
|
+
const cur = wtUi.scope.get(sid)
|
|
1157
|
+
const prs = wtScopesOf(sid).filter((x) => isPrKey(x.key))
|
|
1158
|
+
lastMenuAnchor = btn
|
|
1159
|
+
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',
|
|
1160
|
+
attrs: { 'data-action': 'wt-scope', 'data-id': sid, 'data-scope': x.key } })), { title: 'Walk through one PR' })
|
|
1161
|
+
}
|
|
1162
|
+
// 2026-11-06 a PR row's Open: where to (the session's walkthrough on this PR, or GitHub)
|
|
1163
|
+
function openPrOpenMenu(btn) {
|
|
1164
|
+
const d = btn.dataset, n = '#' + d.num
|
|
1165
|
+
lastMenuAnchor = btn
|
|
1166
|
+
openActionMenu(btn, [
|
|
1167
|
+
{ 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 } },
|
|
1168
|
+
...(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 } }] : []),
|
|
1169
|
+
], { title: 'Open ' + n })
|
|
1170
|
+
}
|
|
1136
1171
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1137
1172
|
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 {} }
|
|
1138
1173
|
function wtRestore(sid) {
|
|
@@ -1142,6 +1177,8 @@ function wtRestore(sid) {
|
|
|
1142
1177
|
}
|
|
1143
1178
|
async function wtOpen(sid, scope = null) {
|
|
1144
1179
|
const s = wtSession(sid)
|
|
1180
|
+
// 2026-11-06 from the PRs page, a session this page hasn't loaded: its page opens on the walkthrough (wtRestore)
|
|
1181
|
+
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 }
|
|
1145
1182
|
if (!s || s.agent) return
|
|
1146
1183
|
wtUi.sid = sid
|
|
1147
1184
|
if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
|
|
@@ -1232,7 +1269,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
|
|
|
1232
1269
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1233
1270
|
// walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
|
|
1234
1271
|
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1235
|
-
|
|
1272
|
+
let k = wtKey(sid)
|
|
1236
1273
|
const had = wtUi.data.get(k)
|
|
1237
1274
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
1238
1275
|
if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
|
|
@@ -1241,6 +1278,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
|
|
|
1241
1278
|
try {
|
|
1242
1279
|
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
|
|
1243
1280
|
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1281
|
+
// 2026-11-06 the old 'pr' (remembered from before): the daemon answers for that PR's own range
|
|
1282
|
+
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) }
|
|
1244
1283
|
wtUi.data.set(k, r)
|
|
1245
1284
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1246
1285
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1319,11 +1358,30 @@ function wtRestoreView(node) {
|
|
|
1319
1358
|
wtStuck(node.querySelector('.wt-scroll'))
|
|
1320
1359
|
}
|
|
1321
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
|
+
}
|
|
1322
1381
|
function wtMarks(s) {
|
|
1323
1382
|
const node = wtUi.node; if (!node || !s) return
|
|
1324
1383
|
const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
|
|
1325
|
-
const rev =
|
|
1326
|
-
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)
|
|
1327
1385
|
for (const a of node.querySelectorAll('article.wt-st')) {
|
|
1328
1386
|
const on = rev.has(a.dataset.step)
|
|
1329
1387
|
a.classList.toggle('done', on)
|
|
@@ -1333,6 +1391,8 @@ function wtMarks(s) {
|
|
|
1333
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
|
|
1334
1392
|
for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
|
|
1335
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)
|
|
1336
1396
|
}
|
|
1337
1397
|
async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
1338
1398
|
const s = wtSession(sid)
|
|
@@ -1358,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
|
1358
1418
|
function wtRevNext() {
|
|
1359
1419
|
const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
|
|
1360
1420
|
const slides = [...deck.children], cur = wtDeckCur()
|
|
1361
|
-
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)
|
|
1362
1425
|
if (!st.show) return void wtSlideGo(cur + 1)
|
|
1363
|
-
const step = slides[cur].dataset.step
|
|
1364
|
-
|
|
1365
|
-
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 })
|
|
1366
1428
|
wtSlideGo(st.to)
|
|
1367
1429
|
}
|
|
1368
1430
|
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
@@ -1413,8 +1475,7 @@ function wtSlideGo(i) {
|
|
|
1413
1475
|
if (nav) {
|
|
1414
1476
|
nav.querySelector('.wt-dprev').disabled = i === 0
|
|
1415
1477
|
nav.querySelector('.wt-dnext').disabled = i === n - 1
|
|
1416
|
-
const
|
|
1417
|
-
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))
|
|
1418
1479
|
const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
|
|
1419
1480
|
const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
|
|
1420
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') })
|
|
@@ -1477,11 +1538,26 @@ function wtExport(kind) {
|
|
|
1477
1538
|
if (!s || !data || data.status !== 'ok') return
|
|
1478
1539
|
const title = s.title || 'Session'
|
|
1479
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))
|
|
1480
|
-
|
|
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
|
+
}
|
|
1481
1553
|
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
|
1482
|
-
const
|
|
1554
|
+
const x = document.createElement('a'); x.href = url; x.download = name; document.body.appendChild(x); x.click(); x.remove()
|
|
1483
1555
|
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
1556
|
+
toast('Saved ' + name + ' · one file, opens offline', 3000)
|
|
1557
|
+
return true
|
|
1484
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>'
|
|
1485
1561
|
// ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
|
|
1486
1562
|
// Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
|
|
1487
1563
|
// number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
|
|
@@ -1615,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
|
|
|
1615
1691
|
// Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
|
|
1616
1692
|
// (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
|
|
1617
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
|
+
}
|
|
1618
1705
|
function wtSpotGo(dir = 1) {
|
|
1619
1706
|
const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
|
|
1620
1707
|
if (!data || data.status !== 'ok' || !wtUi.node) return
|
|
1621
1708
|
const spots = wtSpots(data)
|
|
1622
1709
|
if (!spots.length) return
|
|
1623
1710
|
const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
|
|
1624
|
-
const
|
|
1711
|
+
const { rank, at } = wtSpotAt(data)
|
|
1712
|
+
const i = spotFrom(spots, rank, at, cur, dir)
|
|
1625
1713
|
wtUi.spot.set(data.fingerprint, i)
|
|
1626
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) {
|
|
1627
1723
|
const st = (data.steps || []).find((x) => x.id === sp.step)
|
|
1628
1724
|
const sn = st && st.snippets[sp.snip]
|
|
1629
1725
|
const rows = sn ? rowsOf(sn.patch) : []
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
+
})
|
|
1649
1759
|
}
|
|
1650
1760
|
// Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
|
|
1651
1761
|
// only on a screen 1000px or wider, else unified (the toggle is hidden there)
|
|
@@ -1653,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
|
|
|
1653
1763
|
function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
|
|
1654
1764
|
function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
|
|
1655
1765
|
if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
|
|
1656
|
-
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 }
|
|
1657
1767
|
// the file list folds to a rail ([ or its button) and its edge drags; both kept per device
|
|
1658
1768
|
function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
|
|
1659
1769
|
function wtListUp() {
|
|
@@ -1820,6 +1930,9 @@ function repoRow(shown, f, tail = '') {
|
|
|
1820
1930
|
return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
|
|
1821
1931
|
}
|
|
1822
1932
|
|
|
1933
|
+
// 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
|
|
1934
|
+
// (the counts are per log; a chip's log is its sessions' decisionRepo)
|
|
1935
|
+
const repoReviewHtml = (c) => { const rv = projectsUi.repoReviews || {}; if (!Object.keys(rv).length) return ''; const s = (state.sessions || []).find((x) => x.decisionRepo && rv[x.decisionRepo] && repoKeyOf(x) === c.key); return s ? reviewCountHtml(rv[s.decisionRepo], { esc, cls: 'pchip-rc' }) : '' }
|
|
1823
1936
|
// The repo chip row, shared by the board and the timeline: All, the top 8,
|
|
1824
1937
|
// and More ▾ with a filter box. chips: repoChips() output.
|
|
1825
1938
|
function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
@@ -1830,7 +1943,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1830
1943
|
// with nothing in this view: a filter is never active out of sight.
|
|
1831
1944
|
chips = orderSelectedFirst(chips, selected)
|
|
1832
1945
|
const { shown: row, more } = splitChips(chips, selected, 8)
|
|
1833
|
-
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1946
|
+
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + repoReviewHtml(c) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1834
1947
|
const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
|
|
1835
1948
|
let menu = ''
|
|
1836
1949
|
if (more.length) {
|
|
@@ -1844,13 +1957,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1844
1957
|
// ------------------------------------------------------------ projects (0.9.0)
|
|
1845
1958
|
// A project is a named group of repos (projects.json in the synced folder).
|
|
1846
1959
|
// Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
|
|
1847
|
-
const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
|
|
1960
|
+
const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
|
|
1848
1961
|
async function loadProjects({ fresh = false } = {}) {
|
|
1849
1962
|
if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
|
|
1850
|
-
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1963
|
+
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.repoReviews = d.repoReviews || {}; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1851
1964
|
return projectsUi
|
|
1852
1965
|
}
|
|
1853
|
-
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
|
|
1966
|
+
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; if (d.repoReviews) projectsUi.repoReviews = d.repoReviews; projectsUi.at = Date.now() } }
|
|
1854
1967
|
// The small chip next to the repo: tap = show only this project.
|
|
1855
1968
|
function projChip(s) {
|
|
1856
1969
|
const p = s && s.repoProject
|
|
@@ -2590,9 +2703,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
|
|
|
2590
2703
|
const SESSION_CACHE_MAX = 8
|
|
2591
2704
|
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
2592
2705
|
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
2706
|
+
// 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
|
|
2707
|
+
// (the longest whole-word cut that fits, with …), never mid-word by the
|
|
2708
|
+
// ellipsis; the whole name is in its tip.
|
|
2709
|
+
function fitTitleWords(el, text) {
|
|
2710
|
+
el.textContent = text
|
|
2711
|
+
if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
|
|
2712
|
+
if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
|
|
2713
|
+
let lo = 4, hi = text.length, best = ''
|
|
2714
|
+
while (lo <= hi) {
|
|
2715
|
+
const mid = (lo + hi) >> 1
|
|
2716
|
+
el.textContent = cutTitle(text, mid)
|
|
2717
|
+
if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
|
|
2718
|
+
}
|
|
2719
|
+
el.textContent = best || cutTitle(text, lo)
|
|
2720
|
+
}
|
|
2721
|
+
addEventListener('resize', () => { const t = document.querySelector('#live.full .lf-title'); const full = t && t.getAttribute('data-tip'); if (full) fitTitleWords(t, full) }, { passive: true }) // a turned phone re-fits
|
|
2722
|
+
// 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
|
|
2723
|
+
const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
|
|
2724
|
+
function whereLineHtml(s) {
|
|
2725
|
+
const w = sessionWhere(s)
|
|
2726
|
+
return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
|
|
2727
|
+
}
|
|
2593
2728
|
function sessionShellHtml(s) {
|
|
2594
2729
|
const head = s
|
|
2595
|
-
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1
|
|
2730
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
|
|
2596
2731
|
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
2597
2732
|
return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
|
|
2598
2733
|
'<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
|
|
@@ -2898,8 +3033,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2898
3033
|
// title is being made, its spinner sits in the cluster.
|
|
2899
3034
|
const fullTitle = sessionTitle(s)
|
|
2900
3035
|
const title = canRename
|
|
2901
|
-
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1
|
|
2902
|
-
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1
|
|
3036
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
3037
|
+
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
2903
3038
|
const menu = fsMenuItems(s, mine)
|
|
2904
3039
|
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2905
3040
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
@@ -2945,9 +3080,9 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2945
3080
|
side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
|
|
2946
3081
|
side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
|
|
2947
3082
|
decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
|
|
2948
|
-
if (
|
|
2949
|
-
|
|
2950
|
-
if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
|
|
3083
|
+
if (s.decisionRepo) noteSessionDecisions(s)
|
|
3084
|
+
side += '<div id="fsDecided">' + panelDecidedFold(s) + '</div>' // 2026-11-05 every decision the session made (its repo's log, else all its checkpoints'); 1k Accept / Reject / Edit right here
|
|
3085
|
+
if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths, { log: sessionLogBy(s) }) + '</div>' }, { esc })
|
|
2951
3086
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2952
3087
|
if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
2953
3088
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
@@ -3376,10 +3511,64 @@ document.addEventListener('scroll', (e) => {
|
|
|
3376
3511
|
const el = e.target
|
|
3377
3512
|
if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
|
|
3378
3513
|
const side = el.closest('.fs-side')
|
|
3514
|
+
// 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
|
|
3515
|
+
// content under your finger by the head's change in height, which flipped it back (the sheet jumped)
|
|
3516
|
+
if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
|
|
3379
3517
|
fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
|
|
3380
3518
|
if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
|
|
3381
3519
|
}, true)
|
|
3382
3520
|
|
|
3521
|
+
// 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
|
|
3522
|
+
// raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
|
|
3523
|
+
// it. Anything else is the content's own native scroll, untouched.
|
|
3524
|
+
const sheetMq = window.matchMedia('(max-width: 600px)')
|
|
3525
|
+
{
|
|
3526
|
+
let d = null
|
|
3527
|
+
const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
|
|
3528
|
+
document.addEventListener('touchstart', (e) => {
|
|
3529
|
+
d = null
|
|
3530
|
+
if (!sheetMq.matches || e.touches.length !== 1) return
|
|
3531
|
+
const side = e.target.closest && e.target.closest('#fsSide')
|
|
3532
|
+
if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
|
|
3533
|
+
const fromPeek = !!e.target.closest('.fs-peek')
|
|
3534
|
+
const inner = side.querySelector('.fs-side-in')
|
|
3535
|
+
if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
|
|
3536
|
+
// a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
|
|
3537
|
+
for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
|
|
3538
|
+
const p = e.touches[0]
|
|
3539
|
+
d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
|
|
3540
|
+
}, { passive: true })
|
|
3541
|
+
document.addEventListener('touchmove', (e) => {
|
|
3542
|
+
if (!d || e.touches.length !== 1) return
|
|
3543
|
+
const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
|
|
3544
|
+
if (!d.mode) {
|
|
3545
|
+
d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
|
|
3546
|
+
if (!d.mode) return
|
|
3547
|
+
if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
|
|
3548
|
+
d.travel = travel(d.side)
|
|
3549
|
+
d.side.classList.add('dragging')
|
|
3550
|
+
}
|
|
3551
|
+
if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
|
|
3552
|
+
const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
|
|
3553
|
+
d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
|
|
3554
|
+
}, { passive: false })
|
|
3555
|
+
const end = (e, cancel) => {
|
|
3556
|
+
const g = d
|
|
3557
|
+
d = null
|
|
3558
|
+
if (!g || g.mode !== 'drag') return
|
|
3559
|
+
g.side.classList.remove('dragging')
|
|
3560
|
+
g.side.style.transform = ''
|
|
3561
|
+
const p = e.changedTouches && e.changedTouches[0]
|
|
3562
|
+
if (cancel || !p) return
|
|
3563
|
+
if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
|
|
3564
|
+
toggleFsSheet()
|
|
3565
|
+
sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
document.addEventListener('touchend', (e) => end(e, false), { passive: true })
|
|
3569
|
+
document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
|
|
3570
|
+
}
|
|
3571
|
+
const sheetUi = { ignoreClick: 0 }
|
|
3383
3572
|
function toggleFsSheet() {
|
|
3384
3573
|
const side = document.getElementById('fsSide')
|
|
3385
3574
|
if (!side) return
|
|
@@ -3434,10 +3623,33 @@ function setFullClass(on) {
|
|
|
3434
3623
|
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3435
3624
|
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3436
3625
|
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3437
|
-
const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
|
|
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 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
|
|
3628
|
+
function fitSheetAside() {
|
|
3629
|
+
const html = document.documentElement, a = document.activeElement
|
|
3630
|
+
const typing = isTypingTarget(a)
|
|
3631
|
+
const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
|
|
3632
|
+
const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
|
|
3633
|
+
html.classList.toggle('lf-typing', aside)
|
|
3634
|
+
}
|
|
3635
|
+
// 2026-11-07: the thread at its end stays at its end as full screen changes height (the keyboard, the
|
|
3636
|
+
// sheet stepping aside): Chromium's scroll anchoring put a log that had grown back at its old scrollTop
|
|
3637
|
+
// as it shrank again, the scroll that made read as "you scrolled up" and the newest messages went
|
|
3638
|
+
// under the reply box. Measured before the change, put right after it.
|
|
3639
|
+
function logAtEnd() {
|
|
3640
|
+
const ol = document.querySelector('.live-box.full .live-log')
|
|
3641
|
+
return ol && ol._stick !== false && ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24 ? ol : null
|
|
3642
|
+
}
|
|
3643
|
+
function keepLogEnd(ol) { if (ol && ol.isConnected && ol.scrollHeight - ol.clientHeight - ol.scrollTop > 1) ol.scrollTop = ol.scrollHeight }
|
|
3438
3644
|
function fitFullViewport() {
|
|
3645
|
+
const end = document.documentElement.classList.contains('live-full') ? logAtEnd() : null
|
|
3646
|
+
fitFullViewportNow()
|
|
3647
|
+
keepLogEnd(end)
|
|
3648
|
+
}
|
|
3649
|
+
function fitFullViewportNow() {
|
|
3439
3650
|
const vv = window.visualViewport
|
|
3440
3651
|
const html = document.documentElement, st = html.style
|
|
3652
|
+
fitSheetAside()
|
|
3441
3653
|
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 }
|
|
3442
3654
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3443
3655
|
const strip = document.getElementById('tabStrip')
|
|
@@ -3451,29 +3663,76 @@ function fitFullViewport() {
|
|
|
3451
3663
|
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3452
3664
|
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3453
3665
|
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3666
|
+
if (typing) keepTypingInView()
|
|
3667
|
+
}
|
|
3668
|
+
// 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
|
|
3669
|
+
// bring it back): while you type, check where the composer really is against what you can see, from
|
|
3670
|
+
// this moment's numbers only, and if it isn't in view move full screen by the difference.
|
|
3671
|
+
// (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
|
|
3672
|
+
function keepTypingInView() {
|
|
3673
|
+
const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
|
|
3674
|
+
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3675
|
+
const box = el.closest('.live-box.full')
|
|
3676
|
+
if (!box) return
|
|
3677
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3678
|
+
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3679
|
+
}
|
|
3680
|
+
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
3681
|
+
// page under it) fits full screen then and looks again a few beats after: a swap's last change can
|
|
3682
|
+
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3683
|
+
function onViewportChange(ev) {
|
|
3684
|
+
fitFullViewport()
|
|
3685
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) { settleWhileTyping(1); followFrames() }
|
|
3686
|
+
}
|
|
3687
|
+
// 2026-11-06: and every frame for FOLLOW_FRAMES_MS after the box takes focus or iOS reports a change,
|
|
3688
|
+
// so the keyboard's animation (and iOS's numbers settling a frame or two late) is followed as it goes
|
|
3689
|
+
function followFrames() {
|
|
3690
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
3691
|
+
lfKb.until = performance.now() + FOLLOW_FRAMES_MS
|
|
3692
|
+
if (lfKb.raf) return
|
|
3693
|
+
const frame = () => {
|
|
3694
|
+
lfKb.raf = 0
|
|
3695
|
+
if (!isTypingTarget(document.activeElement) || !document.documentElement.classList.contains('live-full')) return
|
|
3696
|
+
fitFullViewport()
|
|
3697
|
+
if (performance.now() < lfKb.until) lfKb.raf = requestAnimationFrame(frame)
|
|
3698
|
+
}
|
|
3699
|
+
lfKb.raf = requestAnimationFrame(frame)
|
|
3454
3700
|
}
|
|
3455
|
-
|
|
3456
|
-
|
|
3701
|
+
function settleWhileTyping(from = 0) {
|
|
3702
|
+
clearTimeout(lfKb.settle)
|
|
3703
|
+
if (from >= SETTLE_WHILE_TYPING_MS.length) return
|
|
3704
|
+
let i = from
|
|
3705
|
+
const step = () => {
|
|
3706
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3707
|
+
fitFullViewport()
|
|
3708
|
+
if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
|
|
3709
|
+
}
|
|
3710
|
+
lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
|
|
3711
|
+
}
|
|
3712
|
+
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
|
|
3713
|
+
window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
|
|
3714
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
|
|
3457
3715
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3458
3716
|
document.addEventListener('focusin', (e) => {
|
|
3459
3717
|
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3460
|
-
clearTimeout(lfKb.settle)
|
|
3461
3718
|
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3719
|
+
clearTimeout(lfKb.back); lfKb.blurAt = 0
|
|
3720
|
+
{ const end = logAtEnd(); fitSheetAside(); keepLogEnd(end) } // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
|
|
3462
3721
|
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3463
3722
|
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
if (!isTypingTarget(document.activeElement)) return
|
|
3467
|
-
fitFullViewport()
|
|
3468
|
-
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3469
|
-
}
|
|
3470
|
-
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3723
|
+
settleWhileTyping(0)
|
|
3724
|
+
followFrames()
|
|
3471
3725
|
})
|
|
3472
3726
|
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3473
3727
|
// goes down, whatever iOS reports or doesn't on the way.
|
|
3474
3728
|
document.addEventListener('focusout', (e) => {
|
|
3475
3729
|
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3476
3730
|
clearTimeout(lfKb.settle)
|
|
3731
|
+
if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
|
|
3732
|
+
lfKb.blurAt = Date.now()
|
|
3733
|
+
clearTimeout(lfKb.back)
|
|
3734
|
+
lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
|
|
3735
|
+
}
|
|
3477
3736
|
let i = 0
|
|
3478
3737
|
const step = () => {
|
|
3479
3738
|
fitFullViewport()
|
|
@@ -3490,7 +3749,9 @@ function fullAfterRender(sid) {
|
|
|
3490
3749
|
if (box) {
|
|
3491
3750
|
const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
|
|
3492
3751
|
const t = box.querySelector('.lf-title')
|
|
3493
|
-
if (t) t
|
|
3752
|
+
if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
|
|
3753
|
+
const w = box.querySelector('.lf-where')
|
|
3754
|
+
if (w) { w.textContent = sessionWhere(s); if (w.textContent) w.setAttribute('data-tip', wherePathOf(s) || w.textContent); else w.removeAttribute('data-tip') } // 2026-11-05: folder@branch, muted, its own line
|
|
3494
3755
|
paintLfTabs()
|
|
3495
3756
|
const ol = box.querySelector('.live-log')
|
|
3496
3757
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
@@ -3589,7 +3850,7 @@ function liveBoxHtml(s, v, { strip = '' } = {}) {
|
|
|
3589
3850
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
3590
3851
|
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>' +
|
|
3591
3852
|
'<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
|
|
3592
|
-
liveJumpHtml() + '</div>' +
|
|
3853
|
+
liveJumpHtml() + '</div>' + '<div class="lv-subs-slot">' + subStripHtml((v && v.subagents) || s.subagents, esc) + '</div>' +
|
|
3593
3854
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
3594
3855
|
(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
|
|
3595
3856
|
(strip ? '<div class="lf-needs">' + strip + '</div>' : '') + (dock ? '' : backHtml(s)) +
|
|
@@ -3954,6 +4215,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
|
|
|
3954
4215
|
function liveAfterRender(sid) {
|
|
3955
4216
|
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
3956
4217
|
bangAfterRender()
|
|
4218
|
+
slashAfterRender()
|
|
3957
4219
|
const r = liveKeeper.after($app)
|
|
3958
4220
|
hydrateAttThumbs(); barWidths($app)
|
|
3959
4221
|
fullAfterRender(sid)
|
|
@@ -4095,15 +4357,19 @@ const liveOnScreen = () => { const r = state.route; const box = document.querySe
|
|
|
4095
4357
|
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
4096
4358
|
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
4097
4359
|
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
4098
|
-
async function refreshLive(reason) {
|
|
4360
|
+
async function refreshLive(reason, { force = false } = {}) {
|
|
4099
4361
|
const id = liveOnScreen()
|
|
4100
|
-
|
|
4362
|
+
// 2026-11-06: a fetch that was in flight when the phone froze the page may never answer; coming
|
|
4363
|
+
// back (force) doesn't wait behind it, and its late answer is dropped (the newer one wins)
|
|
4364
|
+
if (!id || (liveUi.polling && !(force && Date.now() - (liveUi.triedAt || 0) > 1500))) return false
|
|
4365
|
+
const gen = liveUi.gen = (liveUi.gen || 0) + 1
|
|
4101
4366
|
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
4102
4367
|
let v
|
|
4103
|
-
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
4368
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { if (gen === liveUi.gen) liveUi.polling = false }
|
|
4369
|
+
if (gen !== liveUi.gen) return false
|
|
4104
4370
|
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
4105
4371
|
const s = freshSession(state.sessions.find((x) => x.id === id))
|
|
4106
|
-
const sig = liveSignature(v.messages)
|
|
4372
|
+
const sig = liveSignature(v.messages) + '#' + subSig(v.subagents) // 2026-11-06: a subagent's step changes the thread's line
|
|
4107
4373
|
const before = liveUi.sig[id]
|
|
4108
4374
|
liveUi.sig[id] = sig
|
|
4109
4375
|
if (s && before !== sig) {
|
|
@@ -4113,6 +4379,8 @@ async function refreshLive(reason) {
|
|
|
4113
4379
|
const tmp = document.createElement('div')
|
|
4114
4380
|
tmp.innerHTML = liveBoxHtml(s, v)
|
|
4115
4381
|
const fresh = tmp.querySelector('.live-thread')
|
|
4382
|
+
const slot = box.querySelector('.lv-subs-slot'), freshSlot = tmp.querySelector('.lv-subs-slot') // 2026-11-06: subagents working, outside the kept thread
|
|
4383
|
+
if (slot && freshSlot) slot.innerHTML = freshSlot.innerHTML
|
|
4116
4384
|
if (fresh) {
|
|
4117
4385
|
const next = cur.nextSibling
|
|
4118
4386
|
liveKeeper.before(box)
|
|
@@ -4132,8 +4400,13 @@ async function refreshLive(reason) {
|
|
|
4132
4400
|
// The safety net, and the stream's watchdog, on one slow timer.
|
|
4133
4401
|
const streamDog = createWatchdog()
|
|
4134
4402
|
const wakeGate = createWake()
|
|
4403
|
+
let tickAt = Date.now() // 2026-11-06: the timer's own clock, to notice a frozen page (live-wake.js resumedAfter)
|
|
4135
4404
|
setInterval(() => {
|
|
4405
|
+
const was = tickAt; tickAt = Date.now()
|
|
4136
4406
|
if (document.visibilityState === 'hidden') return
|
|
4407
|
+
const frozen = resumedAfter(was, tickAt)
|
|
4408
|
+
if (frozen) onWake({ type: 'resume', away: frozen })
|
|
4409
|
+
if (renderDue && !busy()) { renderDue = false; render() } // a redraw a wake-up couldn't make while you typed
|
|
4137
4410
|
const id = liveOnScreen()
|
|
4138
4411
|
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')
|
|
4139
4412
|
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
@@ -4141,27 +4414,66 @@ setInterval(() => {
|
|
|
4141
4414
|
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
4142
4415
|
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
4143
4416
|
// open a new stream if the old one missed its pings.
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4417
|
+
let renderDue = false // 2026-11-06: a wake-up's redraw held back while you type (busy()); made once you stop
|
|
4418
|
+
let freshAt = Date.now() // when the view's data was last known current (a refetch that answered)
|
|
4419
|
+
const WAKES = new Set(['visible', 'focus', 'online', 'pageshow-bfcache', 'resume', 'reconnect', 'resync', 'relay-switch'])
|
|
4420
|
+
async function refreshView(reason) {
|
|
4421
|
+
const editing = state.route && ['settings', 'setup'].includes(state.route.name)
|
|
4422
|
+
const id = liveOnScreen()
|
|
4423
|
+
const before = viewSignature(state.sessions, id ? liveUi.sig[id] : '')
|
|
4424
|
+
const wake = WAKES.has(reason)
|
|
4425
|
+
if (reason === 'reconnect' && Date.now() - wakeAt < 2500) return // the stream a wake-up just opened: that wake-up is reading the view
|
|
4426
|
+
const jobs = [id ? refreshLive(reason, { force: wake }) : null, refreshHealth()]
|
|
4427
|
+
if (editing) { /* Settings reads its own sections */ } else if (busy()) renderDue = true
|
|
4428
|
+
else jobs.push(render())
|
|
4429
|
+
await Promise.all(jobs.map((p) => Promise.resolve(p).catch(() => null)))
|
|
4430
|
+
if (!wake) return
|
|
4431
|
+
freshAt = Date.now()
|
|
4432
|
+
const id2 = liveOnScreen()
|
|
4433
|
+
if (viewSignature(state.sessions, id2 ? liveUi.sig[id2] : '') !== before) caughtUpCue()
|
|
4434
|
+
}
|
|
4435
|
+
// The quiet "Updated" a wake-up's refetch shows when it brought something new: a word and a check
|
|
4436
|
+
// (never color alone), no spinner, gone in a moment; still under reduced motion (app.css).
|
|
4437
|
+
function caughtUpCue() {
|
|
4438
|
+
let el = document.getElementById('caughtUp')
|
|
4439
|
+
if (!el) {
|
|
4440
|
+
el = document.createElement('div')
|
|
4441
|
+
el.id = 'caughtUp'; el.className = 'caught-up'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite')
|
|
4442
|
+
el.innerHTML = icon('check') + '<span>Updated</span>'
|
|
4443
|
+
document.body.appendChild(el)
|
|
4444
|
+
}
|
|
4445
|
+
el.hidden = false; el.classList.remove('out'); void el.offsetWidth; el.classList.add('in')
|
|
4446
|
+
clearTimeout(caughtUpCue.t)
|
|
4447
|
+
caughtUpCue.t = setTimeout(() => { el.classList.remove('in'); el.classList.add('out'); caughtUpCue.t = setTimeout(() => { el.hidden = true }, 400) }, 1600)
|
|
4148
4448
|
}
|
|
4149
4449
|
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
4150
4450
|
function onWake(ev) {
|
|
4151
4451
|
if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
|
|
4152
|
-
const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
4452
|
+
const why = ev.type === 'resume' ? 'resume' : wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
4153
4453
|
if (!why) return
|
|
4154
4454
|
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
4155
|
-
const
|
|
4156
|
-
const
|
|
4455
|
+
const now = Date.now()
|
|
4456
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, now, HEARTBEAT_MS + 2000)
|
|
4457
|
+
const away = Math.max(awayAt ? now - awayAt : 0, ev.away || 0)
|
|
4157
4458
|
awayAt = 0
|
|
4158
|
-
// a click back into the window a moment later isn't a wake-up; coming back after a while, a
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
if (
|
|
4459
|
+
// a click back into the window a moment later isn't a wake-up; coming back after a while, a
|
|
4460
|
+
// restore, the network or a frozen page is (live-wake.js catchUp)
|
|
4461
|
+
const plan = catchUp({ why, away, dead, freshAt: Math.max(freshAt, streamDog.lastAt || 0), now })
|
|
4462
|
+
if (!plan.refetch) return
|
|
4463
|
+
// the timer's freeze and the page's own events arrive together: one catch-up
|
|
4464
|
+
if (now - wakeAt < 2500 && !dead) return
|
|
4465
|
+
if (!wakeGate.due() && !plan.reconnect) return
|
|
4466
|
+
wakeAt = now
|
|
4467
|
+
if (plan.reconnect && now - (connect.at || 0) > 2000) connect()
|
|
4162
4468
|
refreshView(why)
|
|
4163
4469
|
}
|
|
4470
|
+
let wakeAt = 0
|
|
4164
4471
|
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
4472
|
+
// 2026-11-06: the relay moved this board to another Mac: a new stream from it, and its data
|
|
4473
|
+
window.addEventListener('leftoff-relay', (e) => {
|
|
4474
|
+
if (!(e.detail && e.detail.switched)) return
|
|
4475
|
+
clearTimeout(onWake.sw); onWake.sw = setTimeout(() => { connect(); refreshView('relay-switch') }, 300)
|
|
4476
|
+
})
|
|
4165
4477
|
// 0.9.15 (ui/away.js): when this board was last on screen here
|
|
4166
4478
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (presence.start()) { openAway.clear(); render() } } else presence.here() })
|
|
4167
4479
|
window.addEventListener('pagehide', () => presence.here())
|
|
@@ -4377,6 +4689,91 @@ function bangAfterRender() {
|
|
|
4377
4689
|
if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
|
|
4378
4690
|
}
|
|
4379
4691
|
}
|
|
4692
|
+
// ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
|
|
4693
|
+
// The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
|
|
4694
|
+
const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
|
|
4695
|
+
function slashFor(ta) {
|
|
4696
|
+
const box = ta && ta.closest('.live-box .composer')
|
|
4697
|
+
if (!box || ta.disabled) return null
|
|
4698
|
+
if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
|
|
4699
|
+
return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
|
|
4700
|
+
}
|
|
4701
|
+
function paintSlash(ta, { keep = false } = {}) {
|
|
4702
|
+
const box = ta && ta.closest('.composer')
|
|
4703
|
+
if (!box) return null
|
|
4704
|
+
const menu = slashFor(ta)
|
|
4705
|
+
let el = box.querySelector('.slash-menu')
|
|
4706
|
+
if (!menu) {
|
|
4707
|
+
if (el) el.remove()
|
|
4708
|
+
box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
|
|
4709
|
+
slashUi.active = -1
|
|
4710
|
+
return null
|
|
4711
|
+
}
|
|
4712
|
+
if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
|
|
4713
|
+
const id = 'slashMenu-' + box.dataset.sid
|
|
4714
|
+
const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
|
|
4715
|
+
if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
|
|
4716
|
+
box.classList.add('has-slash')
|
|
4717
|
+
ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
|
|
4718
|
+
if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
|
|
4719
|
+
const on = box.querySelector('.slash-opt[aria-selected="true"]')
|
|
4720
|
+
if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
|
|
4721
|
+
return menu
|
|
4722
|
+
}
|
|
4723
|
+
function pickSlash(ta, value) {
|
|
4724
|
+
ta.value = value
|
|
4725
|
+
try { ta.setSelectionRange(value.length, value.length) } catch {}
|
|
4726
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
|
|
4727
|
+
ta.focus({ preventScroll: true })
|
|
4728
|
+
}
|
|
4729
|
+
document.addEventListener('input', (ev) => {
|
|
4730
|
+
const ta = ev.target
|
|
4731
|
+
if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
|
|
4732
|
+
if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
|
|
4733
|
+
paintSlash(ta)
|
|
4734
|
+
})
|
|
4735
|
+
// before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
|
|
4736
|
+
window.addEventListener('keydown', (ev) => {
|
|
4737
|
+
const ta = ev.target
|
|
4738
|
+
if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
4739
|
+
const box = ta.closest('.live-box .composer')
|
|
4740
|
+
if (!box || !box.querySelector('.slash-menu')) return
|
|
4741
|
+
const k = slashKey(ev)
|
|
4742
|
+
if (!k) return
|
|
4743
|
+
const menu = slashFor(ta)
|
|
4744
|
+
if (!menu) return
|
|
4745
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
4746
|
+
if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
|
|
4747
|
+
if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
|
|
4748
|
+
const it = menu.items[slashUi.active]
|
|
4749
|
+
if (it && !it.off) pickSlash(ta, it.value)
|
|
4750
|
+
}, true)
|
|
4751
|
+
// a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
|
|
4752
|
+
// for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
|
|
4753
|
+
function pickSlashOpt(o) {
|
|
4754
|
+
if (!o || o.getAttribute('aria-disabled') === 'true') return false
|
|
4755
|
+
const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
|
|
4756
|
+
if (ta) pickSlash(ta, o.dataset.value)
|
|
4757
|
+
return !!ta
|
|
4758
|
+
}
|
|
4759
|
+
let slashTap = null
|
|
4760
|
+
document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
|
|
4761
|
+
document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
|
|
4762
|
+
document.addEventListener('touchstart', (ev) => {
|
|
4763
|
+
const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
|
|
4764
|
+
slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
|
|
4765
|
+
}, { passive: true })
|
|
4766
|
+
document.addEventListener('touchend', (ev) => {
|
|
4767
|
+
const t = slashTap
|
|
4768
|
+
slashTap = null
|
|
4769
|
+
const p = ev.changedTouches && ev.changedTouches[0]
|
|
4770
|
+
if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
|
|
4771
|
+
if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
|
|
4772
|
+
}, { passive: false })
|
|
4773
|
+
function slashAfterRender() {
|
|
4774
|
+
const ta = document.activeElement
|
|
4775
|
+
if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
|
|
4776
|
+
}
|
|
4380
4777
|
async function liveSendClick(btn) {
|
|
4381
4778
|
const box = btn.closest('.live-box')
|
|
4382
4779
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -4503,10 +4900,11 @@ async function aroundWire(id) {
|
|
|
4503
4900
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4504
4901
|
}
|
|
4505
4902
|
}
|
|
4506
|
-
|
|
4903
|
+
// decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
|
|
4904
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
|
|
4507
4905
|
return list.map((cp) =>
|
|
4508
4906
|
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
|
|
4509
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
|
|
4907
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
|
|
4510
4908
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4511
4909
|
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4512
4910
|
(wtSid && cp === newestCp ? '<button type="button" class="linkish wt-since" data-action="wt-open" data-id="' + esc(wtSid) + '" data-scope="checkpoint">' + icon('route') + 'Walk through since here</button>' : '') + // 0.9.17
|
|
@@ -4517,21 +4915,26 @@ function cpHeadLine(cp) {
|
|
|
4517
4915
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4518
4916
|
}
|
|
4519
4917
|
// 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
|
|
4520
|
-
const decisionsUi = {
|
|
4918
|
+
const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4521
4919
|
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4522
|
-
//
|
|
4920
|
+
// 2026-11-05 the session's rows in its repo's log (null until loaded, or with no repo), and the same keyed by text
|
|
4921
|
+
const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
|
|
4922
|
+
const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
|
|
4923
|
+
// The Decided fold: every decision the session made, shown whenever there's at least one
|
|
4924
|
+
function panelDecidedFold(s) {
|
|
4925
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4926
|
+
if (!list.length) return ''
|
|
4927
|
+
return foldHtml({ key: 'cp-decided', title: 'Decided', count: sessionDecisionsCount(list), open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc })
|
|
4928
|
+
}
|
|
4929
|
+
// The session panel reads its repo's log (once per repo per visit, quietly); the old rows stay on screen until the new ones land
|
|
4523
4930
|
function noteSessionDecisions(s) {
|
|
4524
4931
|
const repo = s && s.decisionRepo
|
|
4525
4932
|
if (!repo || decisionsUi.loadedRepo[repo]) return
|
|
4526
4933
|
decisionsUi.loadedRepo[repo] = true
|
|
4527
4934
|
api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
|
|
4528
|
-
|
|
4529
|
-
const
|
|
4530
|
-
|
|
4531
|
-
decisionsUi.bySession[s.id] = by
|
|
4532
|
-
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4533
|
-
const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
|
|
4534
|
-
if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4935
|
+
decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
|
|
4936
|
+
const ps = panelSess()
|
|
4937
|
+
if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
|
|
4535
4938
|
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4536
4939
|
}
|
|
4537
4940
|
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
@@ -4610,11 +5013,26 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4610
5013
|
} catch (e) { toast(e.message || String(e)) }
|
|
4611
5014
|
}
|
|
4612
5015
|
})
|
|
4613
|
-
// 1k: the panel's Decided
|
|
5016
|
+
// 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
|
|
5017
|
+
// count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
|
|
4614
5018
|
function paintPanelDecided(s) {
|
|
4615
|
-
const
|
|
4616
|
-
|
|
4617
|
-
|
|
5019
|
+
const slot = document.getElementById('fsDecided')
|
|
5020
|
+
if (!slot || !s) return
|
|
5021
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
5022
|
+
const fold = slot.querySelector('[data-fold="cp-decided"]')
|
|
5023
|
+
if (!list.length) { if (fold) slot.innerHTML = ''; return }
|
|
5024
|
+
if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
|
|
5025
|
+
const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
|
|
5026
|
+
if (n && n.textContent !== cnt) n.textContent = cnt
|
|
5027
|
+
const box = fold.querySelector('.fs-fold-b')
|
|
5028
|
+
const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
5029
|
+
if (box && box.innerHTML !== html) box.innerHTML = html
|
|
5030
|
+
// the Timeline's older checkpoints read the same log
|
|
5031
|
+
const by = sessionLogBy(s)
|
|
5032
|
+
if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
|
|
5033
|
+
const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
|
|
5034
|
+
if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
|
|
5035
|
+
}
|
|
4618
5036
|
}
|
|
4619
5037
|
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4620
5038
|
function refreshPanelDecisions(s) {
|
|
@@ -4661,6 +5079,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4661
5079
|
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() }
|
|
4662
5080
|
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4663
5081
|
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
5082
|
+
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
|
|
4664
5083
|
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 }
|
|
4665
5084
|
if (a === 'dec-show') {
|
|
4666
5085
|
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
@@ -4737,11 +5156,15 @@ document.addEventListener('submit', (ev) => {
|
|
|
4737
5156
|
decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
|
|
4738
5157
|
})
|
|
4739
5158
|
// The project page's Decisions section
|
|
5159
|
+
// (2026-11-05) or a single repo's page: that repo's log (the page names its id)
|
|
5160
|
+
const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
|
|
4740
5161
|
async function loadProjectDecisions(token, force = false) {
|
|
4741
5162
|
if (!token) return
|
|
5163
|
+
const scopeArg = pageDecisionScope(token)
|
|
5164
|
+
if (!scopeArg) return
|
|
4742
5165
|
try {
|
|
4743
|
-
const v = await api('GET', '/v1/decisions?
|
|
4744
|
-
decisionsUi.project = { token, v }
|
|
5166
|
+
const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
|
|
5167
|
+
decisionsUi.project = { token, v, scopeArg }
|
|
4745
5168
|
paintProjectDecisions()
|
|
4746
5169
|
} catch { decisionsUi.project = null }
|
|
4747
5170
|
}
|
|
@@ -4749,9 +5172,10 @@ function paintProjectDecisions() {
|
|
|
4749
5172
|
const box = document.getElementById('projDecisionsBox')
|
|
4750
5173
|
const pd = decisionsUi.project
|
|
4751
5174
|
if (!box || !pd || pd.token !== qaUi.token) return
|
|
4752
|
-
|
|
5175
|
+
const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
|
|
5176
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
|
|
4753
5177
|
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4754
|
-
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(
|
|
5178
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
|
|
4755
5179
|
}
|
|
4756
5180
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4757
5181
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
@@ -4916,9 +5340,6 @@ async function viewTimeline() {
|
|
|
4916
5340
|
$app.innerHTML = html
|
|
4917
5341
|
}
|
|
4918
5342
|
|
|
4919
|
-
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
4920
|
-
// links there and Start here gets that session's Go / Resume.
|
|
4921
|
-
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
4922
5343
|
function briefAction(id, sessions, viewer) {
|
|
4923
5344
|
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
4924
5345
|
const s = sessions && sessions.get(id)
|
|
@@ -4930,75 +5351,10 @@ function briefAction(id, sessions, viewer) {
|
|
|
4930
5351
|
return b || open
|
|
4931
5352
|
}
|
|
4932
5353
|
|
|
5354
|
+
// 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
|
|
4933
5355
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
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)
|
|
4937
|
-
const out = []
|
|
4938
|
-
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4939
|
-
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
4940
|
-
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
4941
|
-
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
4942
|
-
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
4943
|
-
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
4944
|
-
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
4945
|
-
const openSec = (h) => {
|
|
4946
|
-
if (inSec) out.push('</section>')
|
|
4947
|
-
inSec = true
|
|
4948
|
-
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
4949
|
-
else if (spaces.includes(h)) {
|
|
4950
|
-
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
4951
|
-
'<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>')
|
|
4952
|
-
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
4953
|
-
}
|
|
4954
|
-
for (const raw of body.split('\n')) {
|
|
4955
|
-
const mid = raw.match(MD_SID)
|
|
4956
|
-
const id = mid ? mid[1] : null
|
|
4957
|
-
const line = mid ? raw.slice(0, mid.index) : raw
|
|
4958
|
-
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
4959
|
-
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
4960
|
-
if (st) {
|
|
4961
|
-
openList('ol', 'start')
|
|
4962
|
-
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
4963
|
-
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>' +
|
|
4964
|
-
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
4965
|
-
continue
|
|
4966
|
-
}
|
|
4967
|
-
if (/^- /.test(line)) {
|
|
4968
|
-
openList('ul')
|
|
4969
|
-
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
4970
|
-
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
4971
|
-
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
4972
|
-
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
4973
|
-
// session: the title links to it, the sentence under it, the tags quiet.
|
|
4974
|
-
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
4975
|
-
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
4976
|
-
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 }
|
|
4977
|
-
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 }
|
|
4978
|
-
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
4979
|
-
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
4980
|
-
out.push('<li' + attrs + '>' + li); continue
|
|
4981
|
-
}
|
|
4982
|
-
if (/^\s{2,}\S/.test(line) && inList) {
|
|
4983
|
-
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
4984
|
-
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
4985
|
-
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>' : ''))
|
|
4986
|
-
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
4987
|
-
continue
|
|
4988
|
-
}
|
|
4989
|
-
closeList()
|
|
4990
|
-
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
4991
|
-
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
4992
|
-
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>') } }
|
|
4993
|
-
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
4994
|
-
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 }
|
|
4995
|
-
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
4996
|
-
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
4997
|
-
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
4998
|
-
}
|
|
4999
|
-
closeList()
|
|
5000
|
-
if (inSec) out.push('</section>')
|
|
5001
|
-
return out.join('\n')
|
|
5356
|
+
return digestBodyHtml(md, { spaces, esc, action: (id) => briefAction(id, sessions, viewer),
|
|
5357
|
+
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>' })
|
|
5002
5358
|
}
|
|
5003
5359
|
|
|
5004
5360
|
// "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
|
|
@@ -5107,6 +5463,7 @@ function repaintCalendar() {
|
|
|
5107
5463
|
// repo chips, which only count the picked space.
|
|
5108
5464
|
const digestUi = { standups: {}, date: null, open: null }
|
|
5109
5465
|
async function viewDigest(date, machine) {
|
|
5466
|
+
digestUi.md = null
|
|
5110
5467
|
const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
|
|
5111
5468
|
const byDay = new Map()
|
|
5112
5469
|
for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
|
|
@@ -5135,9 +5492,12 @@ async function viewDigest(date, machine) {
|
|
|
5135
5492
|
if (pick) {
|
|
5136
5493
|
try {
|
|
5137
5494
|
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
5138
|
-
const
|
|
5495
|
+
const ver = ((state.route && state.route.params) || new URLSearchParams()).get('v') // 2026-11-06: an earlier version (ui/digest-versions.js)
|
|
5496
|
+
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)])
|
|
5497
|
+
digestUi.shown = d
|
|
5139
5498
|
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
5140
5499
|
const fm = d.meta || {}
|
|
5500
|
+
digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
|
|
5141
5501
|
const madeAt = fm.generated_at
|
|
5142
5502
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
5143
5503
|
const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
|
|
@@ -5145,12 +5505,13 @@ async function viewDigest(date, machine) {
|
|
|
5145
5505
|
const others = (d.others || []).filter((x) => x.machine || x.shared)
|
|
5146
5506
|
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>' : ''
|
|
5147
5507
|
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>' : ''
|
|
5148
|
-
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
5508
|
+
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 +
|
|
5149
5509
|
'<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>'
|
|
5150
5510
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
5151
5511
|
} else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
|
|
5152
5512
|
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>'
|
|
5153
5513
|
$app.innerHTML = html + '</div>'
|
|
5514
|
+
if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
|
|
5154
5515
|
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
5155
5516
|
const art = $app.querySelector('article.md')
|
|
5156
5517
|
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
@@ -5176,7 +5537,7 @@ function paintDigestFilter() {
|
|
|
5176
5537
|
const spaces = state.digestSpaces || []
|
|
5177
5538
|
const sp = state.digestSpace
|
|
5178
5539
|
const secs = [...document.querySelectorAll('.md section')]
|
|
5179
|
-
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
5540
|
+
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)
|
|
5180
5541
|
const tabsBox = document.getElementById('digestSpaces')
|
|
5181
5542
|
if (tabsBox) {
|
|
5182
5543
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -6060,7 +6421,7 @@ async function viewSettings() {
|
|
|
6060
6421
|
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
6061
6422
|
'<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
|
|
6062
6423
|
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
|
|
6063
|
-
digest: digestSettings(c),
|
|
6424
|
+
digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
|
|
6064
6425
|
spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
|
|
6065
6426
|
sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
|
|
6066
6427
|
agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
|
|
@@ -6107,6 +6468,7 @@ async function viewSettings() {
|
|
|
6107
6468
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
6108
6469
|
'<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>'
|
|
6109
6470
|
showSettingsSection(r)
|
|
6471
|
+
api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
|
|
6110
6472
|
approvedUi.info = starts; approvedUi.err = ''
|
|
6111
6473
|
fillApproved($app, starts) // approved folders: paths as text
|
|
6112
6474
|
const ap = document.getElementById('f-start-approve')
|
|
@@ -6993,7 +7355,7 @@ async function viewProject(token) {
|
|
|
6993
7355
|
const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
|
|
6994
7356
|
qaUi.page = page
|
|
6995
7357
|
paintProject()
|
|
6996
|
-
if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
|
|
7358
|
+
if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
|
|
6997
7359
|
if (fresh) window.scrollTo(0, 0)
|
|
6998
7360
|
const r = state.route || {}
|
|
6999
7361
|
if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
|
|
@@ -7013,7 +7375,7 @@ function paintProject() {
|
|
|
7013
7375
|
const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
7014
7376
|
$app.innerHTML = out.html
|
|
7015
7377
|
fillAsk($app, out.keys)
|
|
7016
|
-
if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
|
|
7378
|
+
if (p.scope && (p.scope.kind === 'project' || p.decisionRepo)) { // 2026-11-03 decision memory: the project's log and where it's kept; 2026-11-05 a repo's log
|
|
7017
7379
|
const at = document.getElementById('projAsks')
|
|
7018
7380
|
if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
|
|
7019
7381
|
paintProjectDecisions()
|
|
@@ -8200,7 +8562,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
8200
8562
|
tip.classList.add('on')
|
|
8201
8563
|
}, delay)
|
|
8202
8564
|
}
|
|
8203
|
-
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
|
|
8565
|
+
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
|
|
8204
8566
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
8205
8567
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
8206
8568
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
@@ -8342,14 +8704,14 @@ function keepPlace() {
|
|
|
8342
8704
|
document.addEventListener('touchstart', (e) => {
|
|
8343
8705
|
if (refreshing || e.touches.length !== 1) { pull = null; return }
|
|
8344
8706
|
const t = e.touches[0]
|
|
8345
|
-
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
|
|
8707
|
+
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
|
|
8346
8708
|
}, { passive: true })
|
|
8347
8709
|
document.addEventListener('touchmove', (e) => {
|
|
8348
8710
|
if (!pull || e.touches.length !== 1) return
|
|
8349
8711
|
const t = e.touches[0]
|
|
8350
8712
|
const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
|
|
8351
8713
|
if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
|
|
8352
|
-
if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
|
|
8714
|
+
if (!pull.head && (window.scrollY || 0) > 0) { pull = null; paint(null); return } // a pull from the bars at the top works scrolled anywhere
|
|
8353
8715
|
if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
|
|
8354
8716
|
pull.step = step
|
|
8355
8717
|
paint(step)
|
|
@@ -8849,14 +9211,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
8849
9211
|
// 0.9.16 the session panel (ui/fs-side.js)
|
|
8850
9212
|
if (action === 'fs-side') return toggleFsSide()
|
|
8851
9213
|
if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
|
|
8852
|
-
if (action === 'fs-sheet') return toggleFsSheet()
|
|
9214
|
+
if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
|
|
8853
9215
|
if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
|
|
8854
9216
|
if (action === 'tab-pin') return togglePin(id)
|
|
8855
9217
|
// 0.9.17 Walk me through it
|
|
8856
|
-
if (action === '
|
|
9218
|
+
if (action === 'pr-open') return openPrOpenMenu(btn)
|
|
9219
|
+
if (action === 'pr-gh') { closeActionMenu(); const u = prSafeUrl(btn.dataset.url); if (u) window.open(u, '_blank', 'noopener,noreferrer'); return }
|
|
9220
|
+
if (action === 'wt-open') { if (btn.closest('.lo-actmenu')) closeActionMenu(); return wtOpen(id, btn.dataset.scope || null) }
|
|
8857
9221
|
if (action === 'wt-close') return wtClose()
|
|
8858
9222
|
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 }
|
|
8859
|
-
if (action === 'wt-
|
|
9223
|
+
if (action === 'wt-prs') return openWtPrMenu(btn, btn.dataset.id || wtUi.sid)
|
|
9224
|
+
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) }
|
|
8860
9225
|
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
8861
9226
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8862
9227
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
|
@@ -8907,6 +9272,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8907
9272
|
if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
|
|
8908
9273
|
if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
|
|
8909
9274
|
if (action === 'wt-export-html') return wtExport('html')
|
|
9275
|
+
if (action === 'digest-save-html') { if (digestUi.md) saveHtmlFile(digestExportHtml(digestUi.md, { date: digestUi.mdDate }), exportFileName('digest', digestUi.mdDate || '')); return } // 2026-11-06
|
|
8910
9276
|
if (action === 'wt-export-md') return wtExport('md')
|
|
8911
9277
|
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
8912
9278
|
if (action === 'copy-fact') {
|
|
@@ -8920,8 +9286,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
8920
9286
|
if (action === 'copy-cmd') {
|
|
8921
9287
|
const ok = await copy(btn.dataset.cmd)
|
|
8922
9288
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
9289
|
+
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)
|
|
8923
9290
|
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
8924
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label',
|
|
9291
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', btn._label); btn.dataset.tip = 'Copy' }, 1500)
|
|
8925
9292
|
}
|
|
8926
9293
|
return
|
|
8927
9294
|
}
|
|
@@ -9541,6 +9908,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9541
9908
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
9542
9909
|
if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
|
|
9543
9910
|
if (action === 'doc-close') return closeDoc()
|
|
9911
|
+
if (action === 'hp-mode' || action === 'hp-show' || action === 'hp-allow' || action === 'hp-block' || action === 'hp-reload' || action === 'hp-show-local') return hpAction(action, btn)
|
|
9544
9912
|
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
9545
9913
|
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
9546
9914
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
@@ -9839,7 +10207,7 @@ async function todoDoneChange(cb) {
|
|
|
9839
10207
|
|
|
9840
10208
|
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
9841
10209
|
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
9842
|
-
// rendered from escaped text; HTML files
|
|
10210
|
+
// rendered from escaped text; HTML files open on Preview (sandboxed) or Code; anything else downloads.
|
|
9843
10211
|
const docUi = { r: null, name: '', printing: null }
|
|
9844
10212
|
function docViewerEl() {
|
|
9845
10213
|
let el = document.getElementById('docViewer')
|
|
@@ -9859,7 +10227,89 @@ function docViewerEl() {
|
|
|
9859
10227
|
document.body.appendChild(el)
|
|
9860
10228
|
return el
|
|
9861
10229
|
}
|
|
9862
|
-
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') }
|
|
10230
|
+
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') }
|
|
10231
|
+
function docBodyLoading(el) { const b = el.querySelector('.dv-body'); b.classList.remove('dv-hpv'); hpUi.r = null; b.innerHTML = '<div class="empty">Opening…</div>' }
|
|
10232
|
+
// 2026-11-06 An HTML file: Preview (ui/html-preview.js, a sandboxed frame; remote
|
|
10233
|
+
// resources blocked until Allow, for this view only) or Code, remembered per device.
|
|
10234
|
+
const hpUi = { r: null, code: '', mode: 'preview', remote: false, open: false, refs: [], locals: [], openLocal: false }
|
|
10235
|
+
function hpViewPref() { try { return localStorage.getItem(HP_VIEW_KEY) === 'code' ? 'code' : 'preview' } catch { return 'preview' } }
|
|
10236
|
+
function mountHtmlPreview(body, r, code, { line = null } = {}) {
|
|
10237
|
+
if (!isHtmlDoc(r)) return false
|
|
10238
|
+
Object.assign(hpUi, { r, code, mode: line ? 'code' : hpViewPref(), remote: false, open: false, refs: remoteRefs(r.text), locals: localRefs(r.text), openLocal: false })
|
|
10239
|
+
body.innerHTML = '<div class="hp-bar"></div><div class="hp-main"></div>'
|
|
10240
|
+
paintHpBar(body); paintHpMain(body)
|
|
10241
|
+
return true
|
|
10242
|
+
}
|
|
10243
|
+
function paintHpBar(body) { const bar = body.querySelector('.hp-bar'); if (bar) bar.innerHTML = previewBarHtml({ ...hpUi, icon }) }
|
|
10244
|
+
function paintHpMain(body) {
|
|
10245
|
+
const main = body.querySelector('.hp-main')
|
|
10246
|
+
if (!main || !hpUi.r) return
|
|
10247
|
+
const pv = hpUi.mode === 'preview'
|
|
10248
|
+
body.classList.toggle('dv-hpv', pv)
|
|
10249
|
+
if (!pv) { main.innerHTML = hpUi.code; return }
|
|
10250
|
+
main.textContent = ''
|
|
10251
|
+
const f = document.createElement('iframe')
|
|
10252
|
+
f.className = 'hp-frame'
|
|
10253
|
+
f.setAttribute('sandbox', 'allow-scripts')
|
|
10254
|
+
f.setAttribute('referrerpolicy', 'no-referrer')
|
|
10255
|
+
f.setAttribute('aria-label', 'Preview of ' + (hpUi.r.name || 'the page'))
|
|
10256
|
+
f._hpDoc = previewDoc(hpUi.r.text, { remote: hpUi.remote })
|
|
10257
|
+
f._hpPing = 0
|
|
10258
|
+
// once the page is in, every load is checked: only the page we wrote answers the ping
|
|
10259
|
+
// (ui/html-preview.js GUARD), so silence means it went somewhere else
|
|
10260
|
+
f.addEventListener('load', () => {
|
|
10261
|
+
if (f._hpDoc != null || !f.isConnected || !f.contentWindow) return
|
|
10262
|
+
const n = ++f._hpPing
|
|
10263
|
+
f.contentWindow.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10264
|
+
clearTimeout(f._hpWait)
|
|
10265
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1000)
|
|
10266
|
+
})
|
|
10267
|
+
f.src = PREVIEW_PAGE + (hpUi.remote ? '?remote=1' : '')
|
|
10268
|
+
main.appendChild(f)
|
|
10269
|
+
}
|
|
10270
|
+
// The page sent itself somewhere (CSP can't stop that): the frame goes, and the bar says so.
|
|
10271
|
+
function hpStopped(f) {
|
|
10272
|
+
if (!f || !f.isConnected) return
|
|
10273
|
+
clearTimeout(f._hpWait)
|
|
10274
|
+
const main = f.closest('.hp-main')
|
|
10275
|
+
f.remove()
|
|
10276
|
+
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>'
|
|
10277
|
+
}
|
|
10278
|
+
function hpAction(action, btn) {
|
|
10279
|
+
const body = document.querySelector('#docViewer .dv-body')
|
|
10280
|
+
if (!body || !hpUi.r) return
|
|
10281
|
+
if (action === 'hp-mode') {
|
|
10282
|
+
const m = btn.dataset.mode === 'code' ? 'code' : 'preview'
|
|
10283
|
+
if (m === hpUi.mode) return
|
|
10284
|
+
hpUi.mode = m
|
|
10285
|
+
try { localStorage.setItem(HP_VIEW_KEY, m) } catch {}
|
|
10286
|
+
paintHpBar(body); paintHpMain(body); body.scrollTop = 0
|
|
10287
|
+
} else if (action === 'hp-show') { hpUi.open = !hpUi.open; paintHpBar(body) }
|
|
10288
|
+
else if (action === 'hp-show-local') { hpUi.openLocal = !hpUi.openLocal; paintHpBar(body) }
|
|
10289
|
+
else if (action === 'hp-reload') paintHpMain(body)
|
|
10290
|
+
else if (action === 'hp-allow' || action === 'hp-block') { hpUi.remote = action === 'hp-allow'; paintHpBar(body); paintHpMain(body) }
|
|
10291
|
+
const again = body.querySelector('[data-action="' + action + '"]') || body.querySelector('[data-action="' + (action === 'hp-allow' ? 'hp-block' : action === 'hp-block' ? 'hp-allow' : action) + '"]')
|
|
10292
|
+
if (again && action !== 'hp-mode') again.focus({ preventScroll: true })
|
|
10293
|
+
}
|
|
10294
|
+
// The sandbox asks for its page once (a frame that navigated away gets nothing), says where a
|
|
10295
|
+
// clicked link goes (it doesn't open), and passes Esc on, since keys in the frame stay there.
|
|
10296
|
+
window.addEventListener('message', (ev) => {
|
|
10297
|
+
const d = ev.data
|
|
10298
|
+
if (!d || typeof d !== 'object' || typeof d.t !== 'string' || !d.t.startsWith('lo-hp-')) return
|
|
10299
|
+
const f = [...document.querySelectorAll('#docViewer .hp-frame')].find((x) => x.contentWindow === ev.source)
|
|
10300
|
+
if (!f) return
|
|
10301
|
+
if (d.t === 'lo-hp-ready' && f._hpDoc != null) {
|
|
10302
|
+
const html = f._hpDoc; f._hpDoc = null
|
|
10303
|
+
ev.source.postMessage({ t: 'lo-hp-html', html }, '*')
|
|
10304
|
+
const n = ++f._hpPing // the write itself may not fire a load: check once now too
|
|
10305
|
+
ev.source.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10306
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1500)
|
|
10307
|
+
}
|
|
10308
|
+
else if (d.t === 'lo-hp-pong') { if (d.n === f._hpPing) { f._hpPong = d.n; clearTimeout(f._hpWait) } }
|
|
10309
|
+
else if (d.t === 'lo-hp-left') hpStopped(f)
|
|
10310
|
+
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)
|
|
10311
|
+
else if (d.t === 'lo-hp-esc') closeDoc()
|
|
10312
|
+
})
|
|
9863
10313
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
9864
10314
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
9865
10315
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -9868,7 +10318,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
9868
10318
|
const name = plan ? 'Plan' : String(path).split('/').pop()
|
|
9869
10319
|
el.querySelector('.dv-title').textContent = name
|
|
9870
10320
|
el.querySelector('.dv-sub').textContent = ''
|
|
9871
|
-
el
|
|
10321
|
+
docBodyLoading(el)
|
|
9872
10322
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
9873
10323
|
docUi.r = null; docUi.name = name
|
|
9874
10324
|
el.hidden = false
|
|
@@ -9880,6 +10330,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
9880
10330
|
el.querySelector('.dv-title').textContent = r.name || name
|
|
9881
10331
|
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(' · ')
|
|
9882
10332
|
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
10333
|
+
mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
|
|
9883
10334
|
el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
|
|
9884
10335
|
docUi.r = r; docUi.name = name
|
|
9885
10336
|
const items = docMenuItems(r)
|
|
@@ -9904,7 +10355,7 @@ async function openToolOutput(sessionId, toolId, label) {
|
|
|
9904
10355
|
const name = label || 'Tool output'
|
|
9905
10356
|
el.querySelector('.dv-title').textContent = name
|
|
9906
10357
|
el.querySelector('.dv-sub').textContent = ''
|
|
9907
|
-
el
|
|
10358
|
+
docBodyLoading(el)
|
|
9908
10359
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
9909
10360
|
docUi.r = null; docUi.name = name
|
|
9910
10361
|
el.hidden = false
|
|
@@ -9985,7 +10436,7 @@ async function loadRefFile() {
|
|
|
9985
10436
|
el.querySelector('.dv-title').textContent = name
|
|
9986
10437
|
el.querySelector('.dv-sub').textContent = ''
|
|
9987
10438
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
|
|
9988
|
-
el
|
|
10439
|
+
docBodyLoading(el)
|
|
9989
10440
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
9990
10441
|
docUi.r = null; docUi.name = name; refUi.r = null
|
|
9991
10442
|
const wasHidden = el.hidden
|
|
@@ -10001,7 +10452,9 @@ async function loadRefFile() {
|
|
|
10001
10452
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
|
|
10002
10453
|
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(' · ')
|
|
10003
10454
|
const body = el.querySelector('.dv-body')
|
|
10004
|
-
|
|
10455
|
+
const code = refBody(r, { line: refUi.line })
|
|
10456
|
+
body.innerHTML = code
|
|
10457
|
+
mountHtmlPreview(body, r, code, { line: refUi.line })
|
|
10005
10458
|
el.querySelector('.dv-ptitle').textContent = r.name || name
|
|
10006
10459
|
if (r.type === 'pdf' && r.base64) {
|
|
10007
10460
|
const b64 = r.base64
|
|
@@ -10236,6 +10689,7 @@ let stream = null
|
|
|
10236
10689
|
function connect() {
|
|
10237
10690
|
if (stream) { try { stream.close() } catch {} }
|
|
10238
10691
|
const es = stream = new EventSource('/v1/stream')
|
|
10692
|
+
connect.at = Date.now()
|
|
10239
10693
|
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
10240
10694
|
streamDog.opened()
|
|
10241
10695
|
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
@@ -10246,6 +10700,9 @@ function connect() {
|
|
|
10246
10700
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
10247
10701
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
10248
10702
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
10703
|
+
// 2026-11-06: the relay's poll lost events (its Mac restarted, another Mac took over, or more
|
|
10704
|
+
// happened than the daemon keeps): read the view again (ui/relay-client.js)
|
|
10705
|
+
es.addEventListener('resync', () => { if (stream === es) refreshView('resync') })
|
|
10249
10706
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
10250
10707
|
// port; if the browser gave up on the stream, open a new one.
|
|
10251
10708
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|