@leftoff/cli 0.9.18-beta.13 → 0.9.18-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +771 -690
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +206 -3
- package/mod/hooks/register.js +8 -2
- package/package.json +1 -1
- package/ui/app.css +102 -2
- package/ui/app.js +224 -118
- package/ui/cp-fold.js +20 -0
- package/ui/decisions.js +2 -1
- package/ui/digest-versions.js +57 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +107 -0
- package/ui/icons.js +1 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +38 -21
- package/ui/settings-nav.js +1 -1
- package/ui/walk-spots.js +35 -1
package/ui/app.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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
7
|
import { fullViewportFit, isTypingTarget, outOfView, sheetAside, keyboardAccessoryPx, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS, FOLLOW_FRAMES_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
6
8
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
@@ -58,15 +60,16 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
58
60
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
|
|
59
61
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
60
62
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
61
|
-
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
|
|
62
64
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
63
|
-
import { spotsFor,
|
|
65
|
+
import { spotsFor, spotFrom, spotTip, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
64
66
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
65
67
|
import { lastReply, ghostShows } from './ghost.js'
|
|
66
68
|
import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
|
|
67
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
|
|
68
70
|
import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
|
|
69
71
|
import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
|
|
72
|
+
import { isHtmlDoc, remoteRefs, previewDoc, previewBarHtml, PREVIEW_PAGE, VIEW_KEY as HP_VIEW_KEY } from './html-preview.js'
|
|
70
73
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
71
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
|
|
72
75
|
import { copyButtonHtml, copyClick } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
|
|
@@ -79,7 +82,9 @@ import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP,
|
|
|
79
82
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
80
83
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
81
84
|
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
82
|
-
import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
85
|
+
import { cpFoldSummary, workItems } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
86
|
+
import { digestBodyHtml } from './render-digest.js' // 2026-11-06 the digest renderer, shared with leftoff export
|
|
87
|
+
import { digestExportHtml, decisionsExportHtml, walkthroughExportHtml, exportFileName } from './render-export.js' // 2026-11-06 Save as HTML (the same file leftoff export writes)
|
|
83
88
|
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
84
89
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
85
90
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
@@ -540,11 +545,15 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
540
545
|
}
|
|
541
546
|
// 0.9.15: what the session is for and how far along, above its latest step
|
|
542
547
|
const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
|
|
543
|
-
|
|
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>'
|
|
544
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>' : '')
|
|
545
553
|
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
546
554
|
(goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
|
|
547
555
|
(rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
|
|
556
|
+
(rows ? '' : doneRow) +
|
|
548
557
|
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
549
558
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
550
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>' : '') +
|
|
@@ -1349,11 +1358,30 @@ function wtRestoreView(node) {
|
|
|
1349
1358
|
wtStuck(node.querySelector('.wt-scroll'))
|
|
1350
1359
|
}
|
|
1351
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
|
+
}
|
|
1352
1381
|
function wtMarks(s) {
|
|
1353
1382
|
const node = wtUi.node; if (!node || !s) return
|
|
1354
1383
|
const data = wtUi.data.get(wtKey(s.id)); if (!data || data.status !== 'ok') return
|
|
1355
|
-
const rev =
|
|
1356
|
-
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)
|
|
1357
1385
|
for (const a of node.querySelectorAll('article.wt-st')) {
|
|
1358
1386
|
const on = rev.has(a.dataset.step)
|
|
1359
1387
|
a.classList.toggle('done', on)
|
|
@@ -1363,6 +1391,8 @@ function wtMarks(s) {
|
|
|
1363
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
|
|
1364
1392
|
for (const g of node.querySelectorAll('.wt-ag[data-step] .wt-ag-ok')) g.hidden = !rev.has(g.closest('.wt-ag').dataset.step)
|
|
1365
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)
|
|
1366
1396
|
}
|
|
1367
1397
|
async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
1368
1398
|
const s = wtSession(sid)
|
|
@@ -1388,11 +1418,13 @@ async function wtReviewed(sid, step, { force = null, advance = true } = {}) {
|
|
|
1388
1418
|
function wtRevNext() {
|
|
1389
1419
|
const node = wtUi.node, deck = node && node.querySelector('#wtDeck'); if (!deck) return
|
|
1390
1420
|
const slides = [...deck.children], cur = wtDeckCur()
|
|
1391
|
-
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)
|
|
1392
1425
|
if (!st.show) return void wtSlideGo(cur + 1)
|
|
1393
|
-
const step = slides[cur].dataset.step
|
|
1394
|
-
|
|
1395
|
-
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 })
|
|
1396
1428
|
wtSlideGo(st.to)
|
|
1397
1429
|
}
|
|
1398
1430
|
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
@@ -1443,8 +1475,7 @@ function wtSlideGo(i) {
|
|
|
1443
1475
|
if (nav) {
|
|
1444
1476
|
nav.querySelector('.wt-dprev').disabled = i === 0
|
|
1445
1477
|
nav.querySelector('.wt-dnext').disabled = i === n - 1
|
|
1446
|
-
const
|
|
1447
|
-
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))
|
|
1448
1479
|
const now = nav.querySelector('#wtDnow'); if (now) now.textContent = (slides[i].getAttribute('aria-label') || '').replace(/^Step \d+ of \d+: /, '')
|
|
1449
1480
|
const c = nav.querySelector('#wtDcount'); if (c) c.textContent = (i + 1) + ' / ' + n
|
|
1450
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') })
|
|
@@ -1507,11 +1538,26 @@ function wtExport(kind) {
|
|
|
1507
1538
|
if (!s || !data || data.status !== 'ok') return
|
|
1508
1539
|
const title = s.title || 'Session'
|
|
1509
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))
|
|
1510
|
-
|
|
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
|
+
}
|
|
1511
1553
|
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
|
1512
|
-
const
|
|
1554
|
+
const x = document.createElement('a'); x.href = url; x.download = name; document.body.appendChild(x); x.click(); x.remove()
|
|
1513
1555
|
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
1556
|
+
toast('Saved ' + name + ' · one file, opens offline', 3000)
|
|
1557
|
+
return true
|
|
1514
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>'
|
|
1515
1561
|
// ---------------------------------------------------------------- 2026-10-30 walkthrough round 3
|
|
1516
1562
|
// Line refs for Ask (asked: "reference line(s) in a file or multiple files for the ask"): a line
|
|
1517
1563
|
// number picks it; shift-click or a drag across numbers, a range; Cmd/Ctrl-click adds another (any
|
|
@@ -1645,37 +1691,71 @@ function wtReveal(key, ri, { flash = false } = {}) {
|
|
|
1645
1691
|
// Next spot (asked: "some sort of jump to next spot to really look at button"): the button and n
|
|
1646
1692
|
// (Shift+N back) walk ui/walk-spots.js's spots across steps; the reason shows as a tag at the line
|
|
1647
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
|
+
}
|
|
1648
1705
|
function wtSpotGo(dir = 1) {
|
|
1649
1706
|
const sid = wtUi.sid, data = sid && wtUi.data.get(wtKey(sid))
|
|
1650
1707
|
if (!data || data.status !== 'ok' || !wtUi.node) return
|
|
1651
1708
|
const spots = wtSpots(data)
|
|
1652
1709
|
if (!spots.length) return
|
|
1653
1710
|
const cur = wtUi.spot.has(data.fingerprint) ? wtUi.spot.get(data.fingerprint) : -1
|
|
1654
|
-
const
|
|
1711
|
+
const { rank, at } = wtSpotAt(data)
|
|
1712
|
+
const i = spotFrom(spots, rank, at, cur, dir)
|
|
1655
1713
|
wtUi.spot.set(data.fingerprint, i)
|
|
1656
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) {
|
|
1657
1723
|
const st = (data.steps || []).find((x) => x.id === sp.step)
|
|
1658
1724
|
const sn = st && st.snippets[sp.snip]
|
|
1659
1725
|
const rows = sn ? rowsOf(sn.patch) : []
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
+
})
|
|
1679
1759
|
}
|
|
1680
1760
|
// Unified / Split (asked: "Should we offer a side by side diff view too?"): kept per device; split
|
|
1681
1761
|
// only on a screen 1000px or wider, else unified (the toggle is hidden there)
|
|
@@ -1683,7 +1763,7 @@ const wtWide = window.matchMedia ? window.matchMedia('(min-width: ' + SPLIT_MIN
|
|
|
1683
1763
|
function wtSplitPref() { try { return localStorage.getItem('lo-wt-split') === '1' } catch { return false } }
|
|
1684
1764
|
function wtSplitOn() { return wtSplitPref() && !!(wtWide && wtWide.matches) }
|
|
1685
1765
|
if (wtWide && wtWide.addEventListener) wtWide.addEventListener('change', () => { if (wtUi.sid && wtSplitPref()) { wtUi.sig = ''; wtFill(wtSession(wtUi.sid)) } })
|
|
1686
|
-
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 }
|
|
1687
1767
|
// the file list folds to a rail ([ or its button) and its edge drags; both kept per device
|
|
1688
1768
|
function wtRailOn() { try { return localStorage.getItem('lo-wt-rail') === '1' } catch { return false } }
|
|
1689
1769
|
function wtListUp() {
|
|
@@ -4995,6 +5075,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4995
5075
|
if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
|
|
4996
5076
|
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4997
5077
|
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
5078
|
+
if (a === 'dec-save-html') { const v = decisionsUi.view; if (v) saveHtmlFile(decisionsExportHtml(v, { all: !!decisionsUi.all }), exportFileName('decisions', (v.scope && (v.scope.name || v.scope.label)) || '')); return } // 2026-11-06
|
|
4998
5079
|
if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
|
|
4999
5080
|
if (a === 'dec-show') {
|
|
5000
5081
|
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
@@ -5255,9 +5336,6 @@ async function viewTimeline() {
|
|
|
5255
5336
|
$app.innerHTML = html
|
|
5256
5337
|
}
|
|
5257
5338
|
|
|
5258
|
-
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
5259
|
-
// links there and Start here gets that session's Go / Resume.
|
|
5260
|
-
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
5261
5339
|
function briefAction(id, sessions, viewer) {
|
|
5262
5340
|
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
5263
5341
|
const s = sessions && sessions.get(id)
|
|
@@ -5269,75 +5347,10 @@ function briefAction(id, sessions, viewer) {
|
|
|
5269
5347
|
return b || open
|
|
5270
5348
|
}
|
|
5271
5349
|
|
|
5350
|
+
// 2026-11-06 the digest's renderer lives in ui/render-digest.js (the CLI's HTML export uses it too)
|
|
5272
5351
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
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)
|
|
5276
|
-
const out = []
|
|
5277
|
-
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
5278
|
-
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
5279
|
-
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
5280
|
-
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
5281
|
-
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
5282
|
-
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
5283
|
-
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
5284
|
-
const openSec = (h) => {
|
|
5285
|
-
if (inSec) out.push('</section>')
|
|
5286
|
-
inSec = true
|
|
5287
|
-
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
5288
|
-
else if (spaces.includes(h)) {
|
|
5289
|
-
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
5290
|
-
'<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>')
|
|
5291
|
-
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
5292
|
-
}
|
|
5293
|
-
for (const raw of body.split('\n')) {
|
|
5294
|
-
const mid = raw.match(MD_SID)
|
|
5295
|
-
const id = mid ? mid[1] : null
|
|
5296
|
-
const line = mid ? raw.slice(0, mid.index) : raw
|
|
5297
|
-
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
5298
|
-
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
5299
|
-
if (st) {
|
|
5300
|
-
openList('ol', 'start')
|
|
5301
|
-
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
5302
|
-
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>' +
|
|
5303
|
-
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
5304
|
-
continue
|
|
5305
|
-
}
|
|
5306
|
-
if (/^- /.test(line)) {
|
|
5307
|
-
openList('ul')
|
|
5308
|
-
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
5309
|
-
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
5310
|
-
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
5311
|
-
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
5312
|
-
// session: the title links to it, the sentence under it, the tags quiet.
|
|
5313
|
-
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
5314
|
-
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
5315
|
-
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 }
|
|
5316
|
-
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 }
|
|
5317
|
-
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
5318
|
-
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
5319
|
-
out.push('<li' + attrs + '>' + li); continue
|
|
5320
|
-
}
|
|
5321
|
-
if (/^\s{2,}\S/.test(line) && inList) {
|
|
5322
|
-
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
5323
|
-
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
5324
|
-
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>' : ''))
|
|
5325
|
-
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
5326
|
-
continue
|
|
5327
|
-
}
|
|
5328
|
-
closeList()
|
|
5329
|
-
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
5330
|
-
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
5331
|
-
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>') } }
|
|
5332
|
-
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
5333
|
-
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 }
|
|
5334
|
-
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
5335
|
-
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
5336
|
-
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
5337
|
-
}
|
|
5338
|
-
closeList()
|
|
5339
|
-
if (inSec) out.push('</section>')
|
|
5340
|
-
return out.join('\n')
|
|
5352
|
+
return digestBodyHtml(md, { spaces, esc, action: (id) => briefAction(id, sessions, viewer),
|
|
5353
|
+
standup: (h) => '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button>' })
|
|
5341
5354
|
}
|
|
5342
5355
|
|
|
5343
5356
|
// "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
|
|
@@ -5446,6 +5459,7 @@ function repaintCalendar() {
|
|
|
5446
5459
|
// repo chips, which only count the picked space.
|
|
5447
5460
|
const digestUi = { standups: {}, date: null, open: null }
|
|
5448
5461
|
async function viewDigest(date, machine) {
|
|
5462
|
+
digestUi.md = null
|
|
5449
5463
|
const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
|
|
5450
5464
|
const byDay = new Map()
|
|
5451
5465
|
for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
|
|
@@ -5474,9 +5488,12 @@ async function viewDigest(date, machine) {
|
|
|
5474
5488
|
if (pick) {
|
|
5475
5489
|
try {
|
|
5476
5490
|
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
5477
|
-
const
|
|
5491
|
+
const ver = ((state.route && state.route.params) || new URLSearchParams()).get('v') // 2026-11-06: an earlier version (ui/digest-versions.js)
|
|
5492
|
+
const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ver ? '?version=' + encodeURIComponent(ver) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
|
|
5493
|
+
digestUi.shown = d
|
|
5478
5494
|
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
5479
5495
|
const fm = d.meta || {}
|
|
5496
|
+
digestUi.md = d.markdown; digestUi.mdDate = pick // 2026-11-06 for Save as HTML
|
|
5480
5497
|
const madeAt = fm.generated_at
|
|
5481
5498
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
5482
5499
|
const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
|
|
@@ -5484,12 +5501,13 @@ async function viewDigest(date, machine) {
|
|
|
5484
5501
|
const others = (d.others || []).filter((x) => x.machine || x.shared)
|
|
5485
5502
|
const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
|
|
5486
5503
|
const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
|
|
5487
|
-
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
5504
|
+
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + '<div class="digest-save">' + saveHtmlBtn('digest-save-html') + '</div>' + also + unnamed +
|
|
5488
5505
|
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
|
|
5489
5506
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
5490
5507
|
} else if (noSessionsYet) html += digestFirstHtml({ cfg: state.cfg, health: state.health, esc }) // 0.9.13: nothing to write about yet
|
|
5491
5508
|
else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
|
|
5492
5509
|
$app.innerHTML = html + '</div>'
|
|
5510
|
+
if (pick && digestUi.shown) { try { paintDigestVersion($app, digestUi.shown, pick, { esc, storage: window.localStorage }) } catch {} digestUi.shown = null }
|
|
5493
5511
|
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
5494
5512
|
const art = $app.querySelector('article.md')
|
|
5495
5513
|
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
@@ -5515,7 +5533,7 @@ function paintDigestFilter() {
|
|
|
5515
5533
|
const spaces = state.digestSpaces || []
|
|
5516
5534
|
const sp = state.digestSpace
|
|
5517
5535
|
const secs = [...document.querySelectorAll('.md section')]
|
|
5518
|
-
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
5536
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !sec.classList.contains('done-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
5519
5537
|
const tabsBox = document.getElementById('digestSpaces')
|
|
5520
5538
|
if (tabsBox) {
|
|
5521
5539
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -6399,7 +6417,7 @@ async function viewSettings() {
|
|
|
6399
6417
|
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
6400
6418
|
'<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
|
|
6401
6419
|
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
|
|
6402
|
-
digest: digestSettings(c),
|
|
6420
|
+
digest: digestSettings(c) + mcpSetupHtml(null, { esc, icon }), // 2026-11-06: leftoff mcp setup (ui/mcp-setup.js)
|
|
6403
6421
|
spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
|
|
6404
6422
|
sync: syncSection(sync, stUi.data, peers) || unavailable('Couldn’t read the sync status from Leftoff.'),
|
|
6405
6423
|
agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
|
|
@@ -6446,6 +6464,7 @@ async function viewSettings() {
|
|
|
6446
6464
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
6447
6465
|
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
6448
6466
|
showSettingsSection(r)
|
|
6467
|
+
api('GET', '/v1/mcp').then((reg) => { const box = document.getElementById('mcpState'); if (box) box.innerHTML = mcpStateHtml(reg, esc) }).catch(() => {})
|
|
6449
6468
|
approvedUi.info = starts; approvedUi.err = ''
|
|
6450
6469
|
fillApproved($app, starts) // approved folders: paths as text
|
|
6451
6470
|
const ap = document.getElementById('f-start-approve')
|
|
@@ -8539,7 +8558,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
8539
8558
|
tip.classList.add('on')
|
|
8540
8559
|
}, delay)
|
|
8541
8560
|
}
|
|
8542
|
-
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
8561
|
+
const tipped = (e) => e.target.closest && e.target.closest('.save-html[data-tip], #tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
8543
8562
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
8544
8563
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
8545
8564
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
@@ -9249,6 +9268,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9249
9268
|
if (action === 'wt-ref-go') { const p = wtUi.picks[Number(btn.dataset.i)]; if (p) wtReveal(p.k, p.a); return }
|
|
9250
9269
|
if (action === 'wt-ask-lines') { const inp = document.getElementById('wtAsk'); if (inp) { inp.focus({ preventScroll: true }); inp.scrollIntoView({ block: 'nearest' }) } return }
|
|
9251
9270
|
if (action === 'wt-export-html') return wtExport('html')
|
|
9271
|
+
if (action === 'digest-save-html') { if (digestUi.md) saveHtmlFile(digestExportHtml(digestUi.md, { date: digestUi.mdDate }), exportFileName('digest', digestUi.mdDate || '')); return } // 2026-11-06
|
|
9252
9272
|
if (action === 'wt-export-md') return wtExport('md')
|
|
9253
9273
|
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
9254
9274
|
if (action === 'copy-fact') {
|
|
@@ -9262,8 +9282,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
9262
9282
|
if (action === 'copy-cmd') {
|
|
9263
9283
|
const ok = await copy(btn.dataset.cmd)
|
|
9264
9284
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
9285
|
+
if (!btn._label) btn._label = btn.getAttribute('aria-label') || 'Copy' // 2026-11-06: each button keeps its own label (Live resume, the MCP setup lines)
|
|
9265
9286
|
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
9266
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label',
|
|
9287
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', btn._label); btn.dataset.tip = 'Copy' }, 1500)
|
|
9267
9288
|
}
|
|
9268
9289
|
return
|
|
9269
9290
|
}
|
|
@@ -9883,6 +9904,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9883
9904
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
9884
9905
|
if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
|
|
9885
9906
|
if (action === 'doc-close') return closeDoc()
|
|
9907
|
+
if (action === 'hp-mode' || action === 'hp-show' || action === 'hp-allow' || action === 'hp-block' || action === 'hp-reload') return hpAction(action, btn)
|
|
9886
9908
|
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
9887
9909
|
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
9888
9910
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
@@ -10181,7 +10203,7 @@ async function todoDoneChange(cb) {
|
|
|
10181
10203
|
|
|
10182
10204
|
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
10183
10205
|
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
10184
|
-
// rendered from escaped text; HTML files
|
|
10206
|
+
// rendered from escaped text; HTML files open on Preview (sandboxed) or Code; anything else downloads.
|
|
10185
10207
|
const docUi = { r: null, name: '', printing: null }
|
|
10186
10208
|
function docViewerEl() {
|
|
10187
10209
|
let el = document.getElementById('docViewer')
|
|
@@ -10201,7 +10223,88 @@ function docViewerEl() {
|
|
|
10201
10223
|
document.body.appendChild(el)
|
|
10202
10224
|
return el
|
|
10203
10225
|
}
|
|
10204
|
-
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
10226
|
+
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; el.querySelector('.dv-body').classList.remove('dv-hpv'); hpUi.r = null; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
10227
|
+
function docBodyLoading(el) { const b = el.querySelector('.dv-body'); b.classList.remove('dv-hpv'); hpUi.r = null; b.innerHTML = '<div class="empty">Opening…</div>' }
|
|
10228
|
+
// 2026-11-06 An HTML file: Preview (ui/html-preview.js, a sandboxed frame; remote
|
|
10229
|
+
// resources blocked until Allow, for this view only) or Code, remembered per device.
|
|
10230
|
+
const hpUi = { r: null, code: '', mode: 'preview', remote: false, open: false, refs: [] }
|
|
10231
|
+
function hpViewPref() { try { return localStorage.getItem(HP_VIEW_KEY) === 'code' ? 'code' : 'preview' } catch { return 'preview' } }
|
|
10232
|
+
function mountHtmlPreview(body, r, code, { line = null } = {}) {
|
|
10233
|
+
if (!isHtmlDoc(r)) return false
|
|
10234
|
+
Object.assign(hpUi, { r, code, mode: line ? 'code' : hpViewPref(), remote: false, open: false, refs: remoteRefs(r.text) })
|
|
10235
|
+
body.innerHTML = '<div class="hp-bar"></div><div class="hp-main"></div>'
|
|
10236
|
+
paintHpBar(body); paintHpMain(body)
|
|
10237
|
+
return true
|
|
10238
|
+
}
|
|
10239
|
+
function paintHpBar(body) { const bar = body.querySelector('.hp-bar'); if (bar) bar.innerHTML = previewBarHtml({ ...hpUi, icon }) }
|
|
10240
|
+
function paintHpMain(body) {
|
|
10241
|
+
const main = body.querySelector('.hp-main')
|
|
10242
|
+
if (!main || !hpUi.r) return
|
|
10243
|
+
const pv = hpUi.mode === 'preview'
|
|
10244
|
+
body.classList.toggle('dv-hpv', pv)
|
|
10245
|
+
if (!pv) { main.innerHTML = hpUi.code; return }
|
|
10246
|
+
main.textContent = ''
|
|
10247
|
+
const f = document.createElement('iframe')
|
|
10248
|
+
f.className = 'hp-frame'
|
|
10249
|
+
f.setAttribute('sandbox', 'allow-scripts')
|
|
10250
|
+
f.setAttribute('referrerpolicy', 'no-referrer')
|
|
10251
|
+
f.setAttribute('aria-label', 'Preview of ' + (hpUi.r.name || 'the page'))
|
|
10252
|
+
f._hpDoc = previewDoc(hpUi.r.text, { remote: hpUi.remote })
|
|
10253
|
+
f._hpPing = 0
|
|
10254
|
+
// once the page is in, every load is checked: only the page we wrote answers the ping
|
|
10255
|
+
// (ui/html-preview.js GUARD), so silence means it went somewhere else
|
|
10256
|
+
f.addEventListener('load', () => {
|
|
10257
|
+
if (f._hpDoc != null || !f.isConnected || !f.contentWindow) return
|
|
10258
|
+
const n = ++f._hpPing
|
|
10259
|
+
f.contentWindow.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10260
|
+
clearTimeout(f._hpWait)
|
|
10261
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1000)
|
|
10262
|
+
})
|
|
10263
|
+
f.src = PREVIEW_PAGE + (hpUi.remote ? '?remote=1' : '')
|
|
10264
|
+
main.appendChild(f)
|
|
10265
|
+
}
|
|
10266
|
+
// The page sent itself somewhere (CSP can't stop that): the frame goes, and the bar says so.
|
|
10267
|
+
function hpStopped(f) {
|
|
10268
|
+
if (!f || !f.isConnected) return
|
|
10269
|
+
clearTimeout(f._hpWait)
|
|
10270
|
+
const main = f.closest('.hp-main')
|
|
10271
|
+
f.remove()
|
|
10272
|
+
if (main) main.innerHTML = '<div class="empty fv-state hp-stopped"><b>Preview stopped</b><span>The page tried to open another address, so it was closed. Code shows what it does.</span><button type="button" class="btn sm" data-action="hp-reload">Load it again</button></div>'
|
|
10273
|
+
}
|
|
10274
|
+
function hpAction(action, btn) {
|
|
10275
|
+
const body = document.querySelector('#docViewer .dv-body')
|
|
10276
|
+
if (!body || !hpUi.r) return
|
|
10277
|
+
if (action === 'hp-mode') {
|
|
10278
|
+
const m = btn.dataset.mode === 'code' ? 'code' : 'preview'
|
|
10279
|
+
if (m === hpUi.mode) return
|
|
10280
|
+
hpUi.mode = m
|
|
10281
|
+
try { localStorage.setItem(HP_VIEW_KEY, m) } catch {}
|
|
10282
|
+
paintHpBar(body); paintHpMain(body); body.scrollTop = 0
|
|
10283
|
+
} else if (action === 'hp-show') { hpUi.open = !hpUi.open; paintHpBar(body) }
|
|
10284
|
+
else if (action === 'hp-reload') paintHpMain(body)
|
|
10285
|
+
else if (action === 'hp-allow' || action === 'hp-block') { hpUi.remote = action === 'hp-allow'; paintHpBar(body); paintHpMain(body) }
|
|
10286
|
+
const again = body.querySelector('[data-action="' + action + '"]') || body.querySelector('[data-action="' + (action === 'hp-allow' ? 'hp-block' : action === 'hp-block' ? 'hp-allow' : action) + '"]')
|
|
10287
|
+
if (again && action !== 'hp-mode') again.focus({ preventScroll: true })
|
|
10288
|
+
}
|
|
10289
|
+
// The sandbox asks for its page once (a frame that navigated away gets nothing), says where a
|
|
10290
|
+
// clicked link goes (it doesn't open), and passes Esc on, since keys in the frame stay there.
|
|
10291
|
+
window.addEventListener('message', (ev) => {
|
|
10292
|
+
const d = ev.data
|
|
10293
|
+
if (!d || typeof d !== 'object' || typeof d.t !== 'string' || !d.t.startsWith('lo-hp-')) return
|
|
10294
|
+
const f = [...document.querySelectorAll('#docViewer .hp-frame')].find((x) => x.contentWindow === ev.source)
|
|
10295
|
+
if (!f) return
|
|
10296
|
+
if (d.t === 'lo-hp-ready' && f._hpDoc != null) {
|
|
10297
|
+
const html = f._hpDoc; f._hpDoc = null
|
|
10298
|
+
ev.source.postMessage({ t: 'lo-hp-html', html }, '*')
|
|
10299
|
+
const n = ++f._hpPing // the write itself may not fire a load: check once now too
|
|
10300
|
+
ev.source.postMessage({ t: 'lo-hp-ping', n }, '*')
|
|
10301
|
+
f._hpWait = setTimeout(() => { if (f._hpPong !== n) hpStopped(f) }, 1500)
|
|
10302
|
+
}
|
|
10303
|
+
else if (d.t === 'lo-hp-pong') { if (d.n === f._hpPing) { f._hpPong = d.n; clearTimeout(f._hpWait) } }
|
|
10304
|
+
else if (d.t === 'lo-hp-left') hpStopped(f)
|
|
10305
|
+
else if (d.t === 'lo-hp-link' && typeof d.href === 'string') toast('Links don’t open from a preview: ' + d.href.slice(0, 160), 5000)
|
|
10306
|
+
else if (d.t === 'lo-hp-esc') closeDoc()
|
|
10307
|
+
})
|
|
10205
10308
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
10206
10309
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
10207
10310
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -10210,7 +10313,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
10210
10313
|
const name = plan ? 'Plan' : String(path).split('/').pop()
|
|
10211
10314
|
el.querySelector('.dv-title').textContent = name
|
|
10212
10315
|
el.querySelector('.dv-sub').textContent = ''
|
|
10213
|
-
el
|
|
10316
|
+
docBodyLoading(el)
|
|
10214
10317
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10215
10318
|
docUi.r = null; docUi.name = name
|
|
10216
10319
|
el.hidden = false
|
|
@@ -10222,6 +10325,7 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
10222
10325
|
el.querySelector('.dv-title').textContent = r.name || name
|
|
10223
10326
|
el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
10224
10327
|
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
10328
|
+
mountHtmlPreview(el.querySelector('.dv-body'), r, docViewerBody(r))
|
|
10225
10329
|
el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
|
|
10226
10330
|
docUi.r = r; docUi.name = name
|
|
10227
10331
|
const items = docMenuItems(r)
|
|
@@ -10246,7 +10350,7 @@ async function openToolOutput(sessionId, toolId, label) {
|
|
|
10246
10350
|
const name = label || 'Tool output'
|
|
10247
10351
|
el.querySelector('.dv-title').textContent = name
|
|
10248
10352
|
el.querySelector('.dv-sub').textContent = ''
|
|
10249
|
-
el
|
|
10353
|
+
docBodyLoading(el)
|
|
10250
10354
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10251
10355
|
docUi.r = null; docUi.name = name
|
|
10252
10356
|
el.hidden = false
|
|
@@ -10327,7 +10431,7 @@ async function loadRefFile() {
|
|
|
10327
10431
|
el.querySelector('.dv-title').textContent = name
|
|
10328
10432
|
el.querySelector('.dv-sub').textContent = ''
|
|
10329
10433
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: refUi.path, mac: false, icon })
|
|
10330
|
-
el
|
|
10434
|
+
docBodyLoading(el)
|
|
10331
10435
|
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
10332
10436
|
docUi.r = null; docUi.name = name; refUi.r = null
|
|
10333
10437
|
const wasHidden = el.hidden
|
|
@@ -10343,7 +10447,9 @@ async function loadRefFile() {
|
|
|
10343
10447
|
el.querySelector('.dv-ref').innerHTML = refBarHtml({ path: r.path || refUi.path, mac, icon })
|
|
10344
10448
|
el.querySelector('.dv-sub').textContent = [r.size ? refSize(r.size) : '', r.lang ? r.lang.toUpperCase() : '', r.peer ? 'on ' + r.peer : '', refUi.line && (r.type === 'code' || r.type === 'text') ? 'line ' + refUi.line : ''].filter(Boolean).join(' · ')
|
|
10345
10449
|
const body = el.querySelector('.dv-body')
|
|
10346
|
-
|
|
10450
|
+
const code = refBody(r, { line: refUi.line })
|
|
10451
|
+
body.innerHTML = code
|
|
10452
|
+
mountHtmlPreview(body, r, code, { line: refUi.line })
|
|
10347
10453
|
el.querySelector('.dv-ptitle').textContent = r.name || name
|
|
10348
10454
|
if (r.type === 'pdf' && r.base64) {
|
|
10349
10455
|
const b64 = r.base64
|