@leftoff/cli 0.9.18-beta.10 → 0.9.18-beta.12
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 +8 -0
- package/bin/leftoff-statusline.js +4 -4
- package/bin/leftoff.js +576 -553
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +50 -12
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +58 -2
- package/ui/app.js +194 -11
- package/ui/chip-fade.js +1 -1
- package/ui/decisions.js +15 -5
- package/ui/fs-side.js +29 -0
- package/ui/live-view.js +16 -2
- package/ui/prs.js +28 -10
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +20 -6
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +14 -0
package/ui/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
-
import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
5
|
+
import { fullViewportFit, isTypingTarget, outOfView, sheetAside, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
6
6
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
7
7
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
8
8
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -43,7 +43,7 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
43
43
|
import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
|
|
44
44
|
import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
|
|
45
45
|
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'
|
|
46
|
+
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
47
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
48
48
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
49
49
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
@@ -54,14 +54,15 @@ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason,
|
|
|
54
54
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
55
55
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
56
56
|
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
|
|
57
|
-
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
57
|
+
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
|
|
58
58
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
59
59
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
60
|
-
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
60
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine, isPrKey, prScopeTip } from './semdiff.js' // 0.9.17 Walk me through it
|
|
61
61
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
62
62
|
import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
63
63
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
64
64
|
import { lastReply, ghostShows } from './ghost.js'
|
|
65
|
+
import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
|
|
65
66
|
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
|
|
66
67
|
import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
|
|
67
68
|
import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
|
|
@@ -1136,8 +1137,27 @@ function card(s) {
|
|
|
1136
1137
|
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1137
1138
|
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1138
1139
|
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1139
|
-
|
|
1140
|
-
function
|
|
1140
|
+
// 2026-11-06 a PR range has no This session · Whole folder: it is the PR's own commits
|
|
1141
|
+
function wtBody(sid, extra = {}) { const sc = wtUi.scope.get(sid) || 'session'; return { scope: sc, ...(fileScope.get() === 'folder' && !isPrKey(sc) ? { files: 'folder' } : {}), ...extra } }
|
|
1142
|
+
function wtKey(sid) { const sc = wtUi.scope.get(sid) || 'session'; return sid + '|' + sc + (fileScope.get() === 'folder' && !isPrKey(sc) ? '|folder' : '') }
|
|
1143
|
+
// the ranges the daemon last listed for a session (any range's answer carries them all)
|
|
1144
|
+
function wtScopesOf(sid) { for (const [k, d] of wtUi.data) if (k.startsWith(sid + '|') && d && Array.isArray(d.scopes)) return d.scopes; return [] }
|
|
1145
|
+
function openWtPrMenu(btn, sid) {
|
|
1146
|
+
const cur = wtUi.scope.get(sid)
|
|
1147
|
+
const prs = wtScopesOf(sid).filter((x) => isPrKey(x.key))
|
|
1148
|
+
lastMenuAnchor = btn
|
|
1149
|
+
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',
|
|
1150
|
+
attrs: { 'data-action': 'wt-scope', 'data-id': sid, 'data-scope': x.key } })), { title: 'Walk through one PR' })
|
|
1151
|
+
}
|
|
1152
|
+
// 2026-11-06 a PR row's Open: where to (the session's walkthrough on this PR, or GitHub)
|
|
1153
|
+
function openPrOpenMenu(btn) {
|
|
1154
|
+
const d = btn.dataset, n = '#' + d.num
|
|
1155
|
+
lastMenuAnchor = btn
|
|
1156
|
+
openActionMenu(btn, [
|
|
1157
|
+
{ 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 } },
|
|
1158
|
+
...(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 } }] : []),
|
|
1159
|
+
], { title: 'Open ' + n })
|
|
1160
|
+
}
|
|
1141
1161
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1142
1162
|
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 {} }
|
|
1143
1163
|
function wtRestore(sid) {
|
|
@@ -1147,6 +1167,8 @@ function wtRestore(sid) {
|
|
|
1147
1167
|
}
|
|
1148
1168
|
async function wtOpen(sid, scope = null) {
|
|
1149
1169
|
const s = wtSession(sid)
|
|
1170
|
+
// 2026-11-06 from the PRs page, a session this page hasn't loaded: its page opens on the walkthrough (wtRestore)
|
|
1171
|
+
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 }
|
|
1150
1172
|
if (!s || s.agent) return
|
|
1151
1173
|
wtUi.sid = sid
|
|
1152
1174
|
if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
|
|
@@ -1237,7 +1259,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
|
|
|
1237
1259
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1238
1260
|
// walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
|
|
1239
1261
|
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1240
|
-
|
|
1262
|
+
let k = wtKey(sid)
|
|
1241
1263
|
const had = wtUi.data.get(k)
|
|
1242
1264
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
1243
1265
|
if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
|
|
@@ -1246,6 +1268,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
|
|
|
1246
1268
|
try {
|
|
1247
1269
|
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
|
|
1248
1270
|
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1271
|
+
// 2026-11-06 the old 'pr' (remembered from before): the daemon answers for that PR's own range
|
|
1272
|
+
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) }
|
|
1249
1273
|
wtUi.data.set(k, r)
|
|
1250
1274
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1251
1275
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -3406,10 +3430,64 @@ document.addEventListener('scroll', (e) => {
|
|
|
3406
3430
|
const el = e.target
|
|
3407
3431
|
if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
|
|
3408
3432
|
const side = el.closest('.fs-side')
|
|
3433
|
+
// 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
|
|
3434
|
+
// content under your finger by the head's change in height, which flipped it back (the sheet jumped)
|
|
3435
|
+
if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
|
|
3409
3436
|
fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
|
|
3410
3437
|
if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
|
|
3411
3438
|
}, true)
|
|
3412
3439
|
|
|
3440
|
+
// 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
|
|
3441
|
+
// raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
|
|
3442
|
+
// it. Anything else is the content's own native scroll, untouched.
|
|
3443
|
+
const sheetMq = window.matchMedia('(max-width: 600px)')
|
|
3444
|
+
{
|
|
3445
|
+
let d = null
|
|
3446
|
+
const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
|
|
3447
|
+
document.addEventListener('touchstart', (e) => {
|
|
3448
|
+
d = null
|
|
3449
|
+
if (!sheetMq.matches || e.touches.length !== 1) return
|
|
3450
|
+
const side = e.target.closest && e.target.closest('#fsSide')
|
|
3451
|
+
if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
|
|
3452
|
+
const fromPeek = !!e.target.closest('.fs-peek')
|
|
3453
|
+
const inner = side.querySelector('.fs-side-in')
|
|
3454
|
+
if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
|
|
3455
|
+
// a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
|
|
3456
|
+
for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
|
|
3457
|
+
const p = e.touches[0]
|
|
3458
|
+
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 }
|
|
3459
|
+
}, { passive: true })
|
|
3460
|
+
document.addEventListener('touchmove', (e) => {
|
|
3461
|
+
if (!d || e.touches.length !== 1) return
|
|
3462
|
+
const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
|
|
3463
|
+
if (!d.mode) {
|
|
3464
|
+
d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
|
|
3465
|
+
if (!d.mode) return
|
|
3466
|
+
if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
|
|
3467
|
+
d.travel = travel(d.side)
|
|
3468
|
+
d.side.classList.add('dragging')
|
|
3469
|
+
}
|
|
3470
|
+
if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
|
|
3471
|
+
const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
|
|
3472
|
+
d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
|
|
3473
|
+
}, { passive: false })
|
|
3474
|
+
const end = (e, cancel) => {
|
|
3475
|
+
const g = d
|
|
3476
|
+
d = null
|
|
3477
|
+
if (!g || g.mode !== 'drag') return
|
|
3478
|
+
g.side.classList.remove('dragging')
|
|
3479
|
+
g.side.style.transform = ''
|
|
3480
|
+
const p = e.changedTouches && e.changedTouches[0]
|
|
3481
|
+
if (cancel || !p) return
|
|
3482
|
+
if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
|
|
3483
|
+
toggleFsSheet()
|
|
3484
|
+
sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
document.addEventListener('touchend', (e) => end(e, false), { passive: true })
|
|
3488
|
+
document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
|
|
3489
|
+
}
|
|
3490
|
+
const sheetUi = { ignoreClick: 0 }
|
|
3413
3491
|
function toggleFsSheet() {
|
|
3414
3492
|
const side = document.getElementById('fsSide')
|
|
3415
3493
|
if (!side) return
|
|
@@ -3464,10 +3542,19 @@ function setFullClass(on) {
|
|
|
3464
3542
|
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3465
3543
|
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3466
3544
|
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3467
|
-
const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
|
|
3545
|
+
const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
|
|
3546
|
+
// 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
|
|
3547
|
+
function fitSheetAside() {
|
|
3548
|
+
const html = document.documentElement, a = document.activeElement
|
|
3549
|
+
const typing = isTypingTarget(a)
|
|
3550
|
+
const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
|
|
3551
|
+
const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
|
|
3552
|
+
html.classList.toggle('lf-typing', aside)
|
|
3553
|
+
}
|
|
3468
3554
|
function fitFullViewport() {
|
|
3469
3555
|
const vv = window.visualViewport
|
|
3470
3556
|
const html = document.documentElement, st = html.style
|
|
3557
|
+
fitSheetAside()
|
|
3471
3558
|
if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
|
|
3472
3559
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3473
3560
|
const strip = document.getElementById('tabStrip')
|
|
@@ -3520,6 +3607,8 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
|
|
|
3520
3607
|
document.addEventListener('focusin', (e) => {
|
|
3521
3608
|
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3522
3609
|
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3610
|
+
clearTimeout(lfKb.back); lfKb.blurAt = 0
|
|
3611
|
+
fitSheetAside() // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
|
|
3523
3612
|
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3524
3613
|
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3525
3614
|
settleWhileTyping(0)
|
|
@@ -3529,6 +3618,11 @@ document.addEventListener('focusin', (e) => {
|
|
|
3529
3618
|
document.addEventListener('focusout', (e) => {
|
|
3530
3619
|
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3531
3620
|
clearTimeout(lfKb.settle)
|
|
3621
|
+
if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
|
|
3622
|
+
lfKb.blurAt = Date.now()
|
|
3623
|
+
clearTimeout(lfKb.back)
|
|
3624
|
+
lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
|
|
3625
|
+
}
|
|
3532
3626
|
let i = 0
|
|
3533
3627
|
const step = () => {
|
|
3534
3628
|
fitFullViewport()
|
|
@@ -4011,6 +4105,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
|
|
|
4011
4105
|
function liveAfterRender(sid) {
|
|
4012
4106
|
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
4013
4107
|
bangAfterRender()
|
|
4108
|
+
slashAfterRender()
|
|
4014
4109
|
const r = liveKeeper.after($app)
|
|
4015
4110
|
hydrateAttThumbs(); barWidths($app)
|
|
4016
4111
|
fullAfterRender(sid)
|
|
@@ -4434,6 +4529,91 @@ function bangAfterRender() {
|
|
|
4434
4529
|
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 } }
|
|
4435
4530
|
}
|
|
4436
4531
|
}
|
|
4532
|
+
// ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
|
|
4533
|
+
// The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
|
|
4534
|
+
const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
|
|
4535
|
+
function slashFor(ta) {
|
|
4536
|
+
const box = ta && ta.closest('.live-box .composer')
|
|
4537
|
+
if (!box || ta.disabled) return null
|
|
4538
|
+
if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
|
|
4539
|
+
return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
|
|
4540
|
+
}
|
|
4541
|
+
function paintSlash(ta, { keep = false } = {}) {
|
|
4542
|
+
const box = ta && ta.closest('.composer')
|
|
4543
|
+
if (!box) return null
|
|
4544
|
+
const menu = slashFor(ta)
|
|
4545
|
+
let el = box.querySelector('.slash-menu')
|
|
4546
|
+
if (!menu) {
|
|
4547
|
+
if (el) el.remove()
|
|
4548
|
+
box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
|
|
4549
|
+
slashUi.active = -1
|
|
4550
|
+
return null
|
|
4551
|
+
}
|
|
4552
|
+
if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
|
|
4553
|
+
const id = 'slashMenu-' + box.dataset.sid
|
|
4554
|
+
const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
|
|
4555
|
+
if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
|
|
4556
|
+
box.classList.add('has-slash')
|
|
4557
|
+
ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
|
|
4558
|
+
if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
|
|
4559
|
+
const on = box.querySelector('.slash-opt[aria-selected="true"]')
|
|
4560
|
+
if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
|
|
4561
|
+
return menu
|
|
4562
|
+
}
|
|
4563
|
+
function pickSlash(ta, value) {
|
|
4564
|
+
ta.value = value
|
|
4565
|
+
try { ta.setSelectionRange(value.length, value.length) } catch {}
|
|
4566
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
|
|
4567
|
+
ta.focus({ preventScroll: true })
|
|
4568
|
+
}
|
|
4569
|
+
document.addEventListener('input', (ev) => {
|
|
4570
|
+
const ta = ev.target
|
|
4571
|
+
if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
|
|
4572
|
+
if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
|
|
4573
|
+
paintSlash(ta)
|
|
4574
|
+
})
|
|
4575
|
+
// before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
|
|
4576
|
+
window.addEventListener('keydown', (ev) => {
|
|
4577
|
+
const ta = ev.target
|
|
4578
|
+
if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
4579
|
+
const box = ta.closest('.live-box .composer')
|
|
4580
|
+
if (!box || !box.querySelector('.slash-menu')) return
|
|
4581
|
+
const k = slashKey(ev)
|
|
4582
|
+
if (!k) return
|
|
4583
|
+
const menu = slashFor(ta)
|
|
4584
|
+
if (!menu) return
|
|
4585
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
4586
|
+
if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
|
|
4587
|
+
if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
|
|
4588
|
+
const it = menu.items[slashUi.active]
|
|
4589
|
+
if (it && !it.off) pickSlash(ta, it.value)
|
|
4590
|
+
}, true)
|
|
4591
|
+
// a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
|
|
4592
|
+
// for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
|
|
4593
|
+
function pickSlashOpt(o) {
|
|
4594
|
+
if (!o || o.getAttribute('aria-disabled') === 'true') return false
|
|
4595
|
+
const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
|
|
4596
|
+
if (ta) pickSlash(ta, o.dataset.value)
|
|
4597
|
+
return !!ta
|
|
4598
|
+
}
|
|
4599
|
+
let slashTap = null
|
|
4600
|
+
document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
|
|
4601
|
+
document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
|
|
4602
|
+
document.addEventListener('touchstart', (ev) => {
|
|
4603
|
+
const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
|
|
4604
|
+
slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
|
|
4605
|
+
}, { passive: true })
|
|
4606
|
+
document.addEventListener('touchend', (ev) => {
|
|
4607
|
+
const t = slashTap
|
|
4608
|
+
slashTap = null
|
|
4609
|
+
const p = ev.changedTouches && ev.changedTouches[0]
|
|
4610
|
+
if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
|
|
4611
|
+
if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
|
|
4612
|
+
}, { passive: false })
|
|
4613
|
+
function slashAfterRender() {
|
|
4614
|
+
const ta = document.activeElement
|
|
4615
|
+
if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
|
|
4616
|
+
}
|
|
4437
4617
|
async function liveSendClick(btn) {
|
|
4438
4618
|
const box = btn.closest('.live-box')
|
|
4439
4619
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -8932,14 +9112,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
8932
9112
|
// 0.9.16 the session panel (ui/fs-side.js)
|
|
8933
9113
|
if (action === 'fs-side') return toggleFsSide()
|
|
8934
9114
|
if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
|
|
8935
|
-
if (action === 'fs-sheet') return toggleFsSheet()
|
|
9115
|
+
if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
|
|
8936
9116
|
if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
|
|
8937
9117
|
if (action === 'tab-pin') return togglePin(id)
|
|
8938
9118
|
// 0.9.17 Walk me through it
|
|
8939
|
-
if (action === '
|
|
9119
|
+
if (action === 'pr-open') return openPrOpenMenu(btn)
|
|
9120
|
+
if (action === 'pr-gh') { closeActionMenu(); const u = prSafeUrl(btn.dataset.url); if (u) window.open(u, '_blank', 'noopener,noreferrer'); return }
|
|
9121
|
+
if (action === 'wt-open') { if (btn.closest('.lo-actmenu')) closeActionMenu(); return wtOpen(id, btn.dataset.scope || null) }
|
|
8940
9122
|
if (action === 'wt-close') return wtClose()
|
|
8941
9123
|
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 }
|
|
8942
|
-
if (action === 'wt-
|
|
9124
|
+
if (action === 'wt-prs') return openWtPrMenu(btn, btn.dataset.id || wtUi.sid)
|
|
9125
|
+
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) }
|
|
8943
9126
|
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
8944
9127
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8945
9128
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
package/ui/chip-fade.js
CHANGED
|
@@ -18,7 +18,7 @@ export function paintFade(track) {
|
|
|
18
18
|
return s
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const SEL = '.repos .track'
|
|
21
|
+
const SEL = '.repos .track, #tabStrip .tab-scroll' // 0.9.18: the tab strip fades where tabs run off too
|
|
22
22
|
// Keeps every chip row's fade right: on first paint (any re-render adds new
|
|
23
23
|
// tracks), on its own scroll, and when its width or its chips' widths change.
|
|
24
24
|
export function watchChipFades(doc = document) {
|
package/ui/decisions.js
CHANGED
|
@@ -10,6 +10,15 @@ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', corr
|
|
|
10
10
|
export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
|
|
11
11
|
export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
|
|
12
12
|
|
|
13
|
+
// 2026-11-05: a status Leftoff set on its own (statusAuto), in words beside the source, and why in the tip.
|
|
14
|
+
export const AUTO_WORDS = { asked: 'automatically', plan: 'automatically', reversed: 'you changed it later' }
|
|
15
|
+
export const AUTO_TIPS = {
|
|
16
|
+
asked: 'Accepted on its own: you picked this answer in the session. Reject keeps it out of new sessions.',
|
|
17
|
+
plan: 'Accepted on its own: you approved this plan in the session. Reject keeps it out of new sessions.',
|
|
18
|
+
reversed: 'Rejected on its own: later in the session you answered the same question differently.',
|
|
19
|
+
}
|
|
20
|
+
const autoOf = (d) => (d && ((d.status === 'accepted' && (d.statusAuto === 'asked' || d.statusAuto === 'plan')) || (d.status === 'rejected' && d.statusAuto === 'reversed')) ? d.statusAuto : null)
|
|
21
|
+
const autoBit = (d, esc) => { const a = autoOf(d); return a ? '<span class="dec-auto" data-tip="' + esc(AUTO_TIPS[a]) + '">' + esc(AUTO_WORDS[a]) + '</span>' : '' }
|
|
13
22
|
const normD = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
14
23
|
const byNewest = (a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
|
|
15
24
|
|
|
@@ -61,11 +70,12 @@ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null
|
|
|
61
70
|
const rp = (hit && (hit.repo || repoId)) || ''
|
|
62
71
|
const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
|
|
63
72
|
const acts = !readOnly && hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
|
|
64
|
-
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
|
|
73
|
+
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
|
|
74
|
+
: !readOnly && hit && may && autoOf(hit) && hit.status === 'accepted' ? '<div class="dec-acts dec-acts-panel dec-acts-quiet">' + btn('reject', 'Reject', '') + '</div>' : ''
|
|
65
75
|
const why = (hit && hit.why) || d.why, alts = (hit && hit.alternatives && hit.alternatives.length ? hit.alternatives : d.alternatives) || []
|
|
66
76
|
// a quiet line first (status · id · where it came from), then the decision in words, then why / over
|
|
67
77
|
const src = '<span class="dec-src">' + esc(SOURCE_WORDS[(hit && hit.source) || d.source] || SOURCE_WORDS.agent) + '</span>'
|
|
68
|
-
const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
|
|
78
|
+
const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src, autoBit(hit, esc)].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
|
|
69
79
|
return '<li class="dec-row' + (hit ? ' dec-row-' + esc(hit.status) : '') + '"' + (hit && !readOnly ? ' id="decp-' + esc(hit.id) + '"' : '') + '><div class="dec-row-h">' + head + '</div>' +
|
|
70
80
|
'<div class="dec-text">' + esc(hit && (hit.edited || d.entry) ? hit.text : d.text) + '</div>' +
|
|
71
81
|
(why ? '<div class="dec-why">' + esc(why) + '</div>' : '') +
|
|
@@ -155,13 +165,13 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
|
|
|
155
165
|
const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
|
|
156
166
|
const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
|
|
157
167
|
const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
|
|
158
|
-
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
|
|
168
|
+
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : (autoOf(d) && d.status === 'accepted' ? btn('reject', 'Reject') : '') + editBtn('linkish')) + link + '</div>'
|
|
159
169
|
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
160
170
|
'<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
|
|
161
171
|
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
162
172
|
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
163
173
|
chainBit(d, byId, esc) + sug +
|
|
164
|
-
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
174
|
+
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (autoBit(d, esc) ? ' · ' + autoBit(d, esc) : '') + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
// The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
|
|
@@ -195,7 +205,7 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
|
195
205
|
(multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
|
|
196
206
|
if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
|
|
197
207
|
if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
|
|
198
|
-
if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
|
|
208
|
+
if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Questions you answered and plans you approved are accepted on their own. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
|
|
199
209
|
const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
|
|
200
210
|
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
|
|
201
211
|
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
package/ui/fs-side.js
CHANGED
|
@@ -80,3 +80,32 @@ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cp
|
|
|
80
80
|
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
81
81
|
resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
// 2026-11-06 The phone's sheet follows a finger: drag its peek up to raise it, and drag the raised
|
|
85
|
+
// sheet down to lower it, from the peek or from its content when that's scrolled to the very top.
|
|
86
|
+
// Everywhere else a finger scrolls the sheet's content natively, both ways (a drag never starts
|
|
87
|
+
// once the content has room above it, nor when the finger first heads the way that scrolls it).
|
|
88
|
+
export const SHEET_DRAG_SLOP = 8 // px a finger moves before it's a drag or a scroll
|
|
89
|
+
export const SHEET_DRAG_FLIP = 64 // px of drag that raises or lowers the sheet on letting go
|
|
90
|
+
// up: the sheet is raised; fromPeek: the finger started on the peek; scrollTop: the content's at the touch's start;
|
|
91
|
+
// dy, dx: the finger's travel so far (down and right positive) -> 'drag' | 'scroll' | null (not decided yet)
|
|
92
|
+
export function sheetDragStart({ up = false, fromPeek = false, scrollTop = 0, dy = 0, dx = 0 } = {}) {
|
|
93
|
+
const ay = Math.abs(dy), ax = Math.abs(dx)
|
|
94
|
+
if (ay < SHEET_DRAG_SLOP && ax < SHEET_DRAG_SLOP) return null
|
|
95
|
+
if (ax > ay) return 'scroll' // sideways: a table or code block, never the sheet
|
|
96
|
+
if (!up) return fromPeek && dy < 0 ? 'drag' : 'scroll' // lowered: only the peek, only upwards
|
|
97
|
+
if (dy <= 0) return 'scroll' // raised, finger heading up: the content scrolls down
|
|
98
|
+
return fromPeek || (Number(scrollTop) || 0) <= 0 ? 'drag' : 'scroll'
|
|
99
|
+
}
|
|
100
|
+
// How far the sheet sits from where it rests while dragged (px, down positive; never past either end).
|
|
101
|
+
// travel: the px between raised and lowered.
|
|
102
|
+
export function sheetDragOffset({ up = false, dy = 0, travel = 0 } = {}) {
|
|
103
|
+
const t = Math.max(0, Number(travel) || 0)
|
|
104
|
+
return up ? Math.round(Math.max(0, Math.min(t, dy))) : Math.round(Math.max(-t, Math.min(0, dy)))
|
|
105
|
+
}
|
|
106
|
+
// Letting go: true when the sheet should change (raise or lower), from the travel or a quick flick.
|
|
107
|
+
export function sheetDragFlips({ up = false, dy = 0, ms = 1000 } = {}) {
|
|
108
|
+
const d = up ? dy : -dy
|
|
109
|
+
if (d >= SHEET_DRAG_FLIP) return true
|
|
110
|
+
return d >= 24 && ms > 0 && d / ms > 0.5 // a flick: 0.5 px/ms
|
|
111
|
+
}
|
package/ui/live-view.js
CHANGED
|
@@ -326,7 +326,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
326
326
|
const who = senderLabel(m)
|
|
327
327
|
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
328
328
|
let body
|
|
329
|
+
const cmd = isCommandEntry(m)
|
|
329
330
|
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
331
|
+
else if (cmd) body = commandHtml(m, { esc })
|
|
330
332
|
else if (m.from === 'you') {
|
|
331
333
|
const a = messageAttachments(m)
|
|
332
334
|
// 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
|
|
@@ -352,6 +354,8 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
352
354
|
// 0.9.11: Copy, once the reply is whole (none while it streams)
|
|
353
355
|
const cp = m.done ? copyButtonHtml(m.text) : ''
|
|
354
356
|
if (cp) foot += '<div class="msg-foot">' + cp + '</div>'
|
|
357
|
+
} else if (cmd && m.status && m.status.phase === 'replied') {
|
|
358
|
+
// ran: what it printed is the body's; nothing to add
|
|
355
359
|
} else if (m.status && m.from === 'you' && isQueued(m.status)) {
|
|
356
360
|
foot = queuedHtml(m, { esc, may })
|
|
357
361
|
} else if (m.status) {
|
|
@@ -369,6 +373,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
369
373
|
return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
// A slash command sent from the board (ui/slash-commands.js; src/live-link.js sendCommand): the command
|
|
377
|
+
// in mono, then what Claude Code printed, once the transcript shows it ran (settleCommands).
|
|
378
|
+
export function isCommandEntry(m) { return !!m && m.from === 'you' && m.command === true }
|
|
379
|
+
export function commandHtml(m, { esc }) {
|
|
380
|
+
const ran = !!(m.status && m.status.phase === 'replied')
|
|
381
|
+
const out = typeof m.out === 'string' ? m.out.trim() : ''
|
|
382
|
+
return '<div class="lv-text lv-cmd"><code>' + esc(m.text || '') + '</code></div>' +
|
|
383
|
+
(out ? '<pre class="lv-cmd-out">' + esc(out) + '</pre>' : ran ? '<div class="lv-cmd-out lv-cmd-done">Done</div>' : '')
|
|
384
|
+
}
|
|
385
|
+
|
|
372
386
|
// 0.9.15: an answer that is still the Stop hook's words, clipped by the mod (280 characters ending in "…")
|
|
373
387
|
export function partialAnswer(m) { return !!(m && m.from === 'claude' && m.via === 'turn' && typeof m.text === 'string' && /…\s*$/.test(m.text)) }
|
|
374
388
|
|
|
@@ -565,7 +579,7 @@ export function threadHtml(msgs, opts) {
|
|
|
565
579
|
// 0.9.14: Edit on the message; Retry on Claude's "stopped" card (or on the message, when it didn't arrive)
|
|
566
580
|
const acts = m === tgt ? { retry: m.status.phase === 'failed', edit: true, busy } : null
|
|
567
581
|
html += entryHtml(m, acts ? { ...opts, lvActs: acts } : opts)
|
|
568
|
-
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
|
|
582
|
+
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent' && !isCommandEntry(m)) { // a command has no turn: what it printed is in its own bubble
|
|
569
583
|
// the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
|
|
570
584
|
html += upTo(next == null ? Infinity : next)
|
|
571
585
|
html += pendingClaudeHtml(m, m === tgt ? { ...opts, lvActs: { retry: true, busy } } : opts)
|
|
@@ -664,7 +678,7 @@ export { ranWithoutAsking }
|
|
|
664
678
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
665
679
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
666
680
|
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
667
|
-
export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
|
|
681
|
+
export function entrySig(m) { return JSON.stringify([m.text, m.out == null ? null : m.out, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
|
|
668
682
|
|
|
669
683
|
// ------------------------------------------------------------ the DOM keeper
|
|
670
684
|
|
package/ui/prs.js
CHANGED
|
@@ -5,7 +5,7 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
|
|
|
5
5
|
import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst, PROJECT_KEY } from './repos.js'
|
|
6
6
|
import { renderDoc } from './handoffs.js'
|
|
7
7
|
import { COPY_IC, DONE_IC, COPIED_TIP, COPIED_MS, copyIconClick } from './copy-icon.js'
|
|
8
|
-
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
8
|
+
export const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
9
9
|
|
|
10
10
|
export function shortAgo(iso, now = Date.now()) {
|
|
11
11
|
const t = Date.parse(iso || '')
|
|
@@ -93,24 +93,38 @@ export async function prCopyClick(btn, env = {}) {
|
|
|
93
93
|
export function prMoreHtml(p, open = false) {
|
|
94
94
|
return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
|
|
95
95
|
}
|
|
96
|
+
// 2026-11-06 Walk me through this PR: the session's walkthrough opened on this PR's own range (src/walk-pr.js)
|
|
97
|
+
export const WALK_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>'
|
|
98
|
+
export { GH_MARK }
|
|
99
|
+
const OPEN_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
|
|
100
|
+
export const prWalkScope = (p) => (p && p.repo && p.number ? 'pr:' + p.repo + '#' + p.number : null)
|
|
101
|
+
// One Open button: a menu asking where (Walk me through it in Leftoff, or GitHub) when the PR has a walkthrough;
|
|
102
|
+
// else the plain GitHub link. (Joel, 2026-11-06: four glyphs took the title's room.)
|
|
103
|
+
export function prOpenHtml(p, walkId) {
|
|
104
|
+
const url = safeUrl(p.url), sc = walkId && prWalkScope(p), n = '#' + (p.number || '')
|
|
105
|
+
if (sc) return '<button type="button" class="pr-ic pr-open" data-action="pr-open" data-id="' + esc(walkId) + '" data-scope="' + esc(sc) + '" data-url="' + esc(url || '') + '" data-repo="' + esc(p.repo || '') + '" data-num="' + esc(p.number) + '" aria-haspopup="menu" data-tip="Open in Leftoff or on GitHub" aria-label="' + esc('Open ' + n + '…') + '">' + OPEN_IC + '</button>'
|
|
106
|
+
return url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : ''
|
|
107
|
+
}
|
|
96
108
|
// 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
// (2026-11-06 Open asks Leftoff or GitHub, when the row is a session's)
|
|
110
|
+
export function prGhOnlyHtml(p, { walkId = null } = {}) {
|
|
111
|
+
const open = prOpenHtml(p, walkId)
|
|
112
|
+
return open ? '<div class="pr-actions">' + open + '</div>' : ''
|
|
100
113
|
}
|
|
101
|
-
export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
114
|
+
export function prActionsHtml(p, { unlinkId = null, walkId = null } = {}) {
|
|
102
115
|
const url = safeUrl(p.url), n = '#' + (p.number || '')
|
|
103
116
|
if (!url && !unlinkId) return ''
|
|
104
117
|
const copyLabel = 'Copy link to ' + n
|
|
105
118
|
return '<div class="pr-actions">' +
|
|
106
119
|
(url ? '<button type="button" class="pr-ic pr-copy" data-action="pr-copy" data-copy="' + esc(url) + '" data-tip="' + PR_COPY_TIP + '" data-label="' + esc(copyLabel) + '" aria-label="' + esc(copyLabel) + '">' + COPY_IC + DONE_IC + '</button>' : '') +
|
|
107
|
-
(
|
|
120
|
+
prOpenHtml(p, walkId) +
|
|
108
121
|
(unlinkId ? '<button type="button" class="pr-ic pr-unlink" data-action="pr-unlink" data-id="' + esc(unlinkId) + '" data-url="' + esc(p.url) + '" data-tip="Unlink from this session" aria-label="Unlink ' + esc(n) + ' from this session">' + UNLINK + '</button>' : '') +
|
|
109
122
|
'</div>'
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
113
126
|
export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
|
|
127
|
+
const walkId = s && !s.agent ? s.id : null
|
|
114
128
|
// 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
|
|
115
129
|
const more = (s.prsMore || []).filter((p) => p && p.number)
|
|
116
130
|
const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
|
|
@@ -122,11 +136,13 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
122
136
|
// the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
|
|
123
137
|
h += '<div class="prlist pr-compact">' + list.map((p) => {
|
|
124
138
|
const isOpen = !!(open && open.has && open.has(p.url))
|
|
125
|
-
|
|
139
|
+
// how many glyphs sit at the right (the title line leaves them room when open)
|
|
140
|
+
const acts = isOpen ? (safeUrl(p.url) ? 1 : 0) + (safeUrl(p.url) || (prWalkScope(p) && walkId) ? 1 : 0) + (mayEdit ? 1 : 0) : 0
|
|
141
|
+
return '<article class="prrow slim' + (isOpen ? ' open pr-acts-' + acts : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
126
142
|
'<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
|
|
127
143
|
'<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
|
|
128
|
-
(isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
129
|
-
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
|
|
144
|
+
(isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + (checksHtml(p.checks) ? ' · ' + checksHtml(p.checks) : '') + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
145
|
+
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null, walkId }) : prGhOnlyHtml(p, { walkId })) + '</article>'
|
|
130
146
|
}).join('') + '</div>'
|
|
131
147
|
}
|
|
132
148
|
// several refs that couldn't be checked fold into one "+4 not checked" row
|
|
@@ -198,12 +214,14 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
|
|
|
198
214
|
: [p.repo, p.branch, when].filter(Boolean)
|
|
199
215
|
const why = whyText(r)
|
|
200
216
|
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
217
|
+
// 2026-11-06 Walk me through this PR: in its newest session's walkthrough (never a scripted run's)
|
|
218
|
+
const walkTo = !team ? (r.sessions || []).find((x) => x && x.id && !x.nonInteractive && x.archivedReason !== 'non_interactive' && !x.agent) : null
|
|
201
219
|
return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
|
|
202
220
|
'<div class="pr-line1">' + line1 + '</div>' +
|
|
203
221
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
204
222
|
'<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
|
|
205
223
|
sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
|
|
206
|
-
prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null }) + '</article>'
|
|
224
|
+
prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null, walkId: walkTo ? walkTo.id : null }) + '</article>'
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
// ---------------------------------------------------------------- 0.8.5: details on tap
|