@leftoff/cli 0.9.18-beta.11 → 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 +1 -0
- package/bin/leftoff.js +553 -550
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +13 -0
- package/ui/app.js +33 -7
- package/ui/prs.js +28 -10
- package/ui/semdiff.js +20 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.12",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/register.js
CHANGED
|
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
|
|
|
32
32
|
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
33
33
|
|
|
34
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
35
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.12' // keep in step with .claude-plugin/plugin.json
|
|
36
36
|
|
|
37
37
|
const st = {
|
|
38
38
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.12",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -1578,6 +1578,7 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1578
1578
|
.pr-ic[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% - 4px); right: 0; width: max-content; max-width: min(260px, 70vw); padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; text-align: left; white-space: nowrap; letter-spacing: normal; pointer-events: none; z-index: 30; }
|
|
1579
1579
|
@media (hover: hover) and (pointer: fine) { .pr-ic[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
1580
1580
|
.pr-ic[data-tip]:focus-visible::after, .pr-ic.copied[data-tip]::after { display: block; }
|
|
1581
|
+
.pr-ic[aria-expanded="true"][data-tip]::after { display: none !important; } /* 2026-11-06 Open's menu is up: its tip would cover the title */
|
|
1581
1582
|
@media (hover: hover) and (pointer: fine) { .pr-ic.copied[data-tip]:hover::after { animation: none; } }
|
|
1582
1583
|
@media (prefers-reduced-motion: reduce) { .pr-ic[data-tip]::after { animation: none !important; } }
|
|
1583
1584
|
.prlist { margin-bottom: 8px; }
|
|
@@ -1641,6 +1642,9 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1641
1642
|
.prrow .pr-line1 { padding-right: 124px; min-height: 24px; }
|
|
1642
1643
|
.prrow:not(:has(.pr-unlink)) .pr-line1 { padding-right: 80px; }
|
|
1643
1644
|
.prrow:not(:has(.pr-ic)) .pr-line1 { padding-right: 0; }
|
|
1645
|
+
/* a closed compact row (the session panel) keeps its glyphs in the row, after the one-line title */
|
|
1646
|
+
.pr-compact .prrow:not(.open) .pr-actions { position: static; margin: 0 -2px 0 0; }
|
|
1647
|
+
.pr-compact .prrow:not(.open) { align-items: center; }
|
|
1644
1648
|
.pr-actions, .prrow.open .pr-actions { position: absolute; top: 0; right: 2px; margin: 0; }
|
|
1645
1649
|
}
|
|
1646
1650
|
|
|
@@ -4636,6 +4640,7 @@ html:not(.live-full) .fs-side { display: none; }
|
|
|
4636
4640
|
.wt-seg::-webkit-scrollbar { display: none; }
|
|
4637
4641
|
.wt-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 38px; padding: 1px 14px 0; box-sizing: border-box; border: 0; border-radius: 8px; background: none; color: var(--muted); font: inherit; font-size: 13px; font-weight: 550; line-height: 1; white-space: nowrap; cursor: pointer; }
|
|
4638
4642
|
.wt-seg button .ic { width: 14px; height: 14px; margin-top: -1px; }
|
|
4643
|
+
.wt-seg button .wt-seg-chev { width: 12px; height: 12px; margin: -1px -4px 0 -2px; opacity: .7; }
|
|
4639
4644
|
.wt-seg button[aria-pressed="true"] { background: var(--card2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
|
|
4640
4645
|
.wt-seg button:disabled { opacity: .45; cursor: default; }
|
|
4641
4646
|
.wt-seg-sm button { height: 34px; padding: 1px 12px 0; }
|
|
@@ -5246,6 +5251,14 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5246
5251
|
.pr-compact .pr-title-btn { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 500; color: var(--text); }
|
|
5247
5252
|
.pr-compact .pr-meta { margin-top: 2px; font-size: 12px; }
|
|
5248
5253
|
.pr-compact .pr-actions { margin: 0 -2px 0 0; }
|
|
5254
|
+
/* open: the actions sit at the top right beside the title line, so the details below take the panel's full width */
|
|
5255
|
+
.pr-compact .prrow.open { position: relative; }
|
|
5256
|
+
.pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 48px; }
|
|
5257
|
+
.pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 92px; }
|
|
5258
|
+
.pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 136px; }
|
|
5259
|
+
.pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 180px; }
|
|
5260
|
+
.pr-compact .prrow.open .pr-actions { position: absolute; top: 8px; right: 4px; margin: 0; }
|
|
5261
|
+
@media (pointer: fine) { .pr-compact .prrow.open.pr-acts-1 .pr-c1 { padding-right: 40px; } .pr-compact .prrow.open.pr-acts-2 .pr-c1 { padding-right: 72px; } .pr-compact .prrow.open.pr-acts-3 .pr-c1 { padding-right: 104px; } .pr-compact .prrow.open.pr-acts-4 .pr-c1 { padding-right: 136px; } }
|
|
5249
5262
|
@media (pointer: fine) { .pr-compact .pr-actions .pr-ic { width: 32px; height: 32px; } }
|
|
5250
5263
|
.sprs .pr-unfold { margin: 0 0 8px; }
|
|
5251
5264
|
.sprs .pr-unfold > .pr-unsum { list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; line-height: 1; padding: 0 10px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
|
package/ui/app.js
CHANGED
|
@@ -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'
|
|
@@ -57,7 +57,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
57
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
|
|
@@ -1137,8 +1137,27 @@ function card(s) {
|
|
|
1137
1137
|
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1138
1138
|
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1139
1139
|
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1140
|
-
|
|
1141
|
-
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
|
+
}
|
|
1142
1161
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1143
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 {} }
|
|
1144
1163
|
function wtRestore(sid) {
|
|
@@ -1148,6 +1167,8 @@ function wtRestore(sid) {
|
|
|
1148
1167
|
}
|
|
1149
1168
|
async function wtOpen(sid, scope = null) {
|
|
1150
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 }
|
|
1151
1172
|
if (!s || s.agent) return
|
|
1152
1173
|
wtUi.sid = sid
|
|
1153
1174
|
if (scope) { wtUi.scope.set(sid, scope); wtUi.fellBack.delete(sid) }
|
|
@@ -1238,7 +1259,7 @@ function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid
|
|
|
1238
1259
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1239
1260
|
// walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
|
|
1240
1261
|
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1241
|
-
|
|
1262
|
+
let k = wtKey(sid)
|
|
1242
1263
|
const had = wtUi.data.get(k)
|
|
1243
1264
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
1244
1265
|
if (wtUi.loading.has(k) || (wtUi.data.has(k) && !fresh && !update)) return wtFill(wtSession(sid))
|
|
@@ -1247,6 +1268,8 @@ async function wtLoad(sid, { fresh = false, update = false, peek = false } = {})
|
|
|
1247
1268
|
try {
|
|
1248
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
|
|
1249
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) }
|
|
1250
1273
|
wtUi.data.set(k, r)
|
|
1251
1274
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1252
1275
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -9093,10 +9116,13 @@ document.addEventListener('click', async (ev) => {
|
|
|
9093
9116
|
if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
|
|
9094
9117
|
if (action === 'tab-pin') return togglePin(id)
|
|
9095
9118
|
// 0.9.17 Walk me through it
|
|
9096
|
-
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) }
|
|
9097
9122
|
if (action === 'wt-close') return wtClose()
|
|
9098
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 }
|
|
9099
|
-
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) }
|
|
9100
9126
|
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
9101
9127
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
9102
9128
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
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
|
package/ui/semdiff.js
CHANGED
|
@@ -371,11 +371,25 @@ function fileMapHtml(data, { esc, icon, cur }) {
|
|
|
371
371
|
return h + '<div class="wt-fm-k"><span class="kbd">j</span><span class="kbd">k</span> next step</div></nav>'
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// 2026-11-06 every PR the session has is its own range ('pr:<owner/repo>#<n>'): up to PR_CHIPS of them are
|
|
375
|
+
// chips; more fold into one "PRs" chip that opens a menu (the one picked shows its number)
|
|
376
|
+
export const PR_CHIPS = 2
|
|
377
|
+
export const isPrKey = (k) => k === 'pr' || /^pr:/.test(String(k || ''))
|
|
378
|
+
const PR_WORD = { merged: 'merged', closed: 'closed', draft: 'draft' }
|
|
379
|
+
export function prScopeTip(x) { return [x.title, x.repo, x.state ? (x.state === 'open' ? 'Open' : x.state[0].toUpperCase() + x.state.slice(1)) : ''].filter(Boolean).join(' · ') }
|
|
374
380
|
function scopesHtml(data, scope, { esc, icon, sid }) {
|
|
375
381
|
const sc = (data && data.scopes) || [{ key: 'checkpoint', label: SCOPE_WORDS.checkpoint, available: false }, { key: 'session', label: SCOPE_WORDS.session, available: true }]
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
(x.key
|
|
382
|
+
const prs = sc.filter((x) => isPrKey(x.key)), rest = sc.filter((x) => !isPrKey(x.key))
|
|
383
|
+
const chip = (x) => '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : isPrKey(x.key) && prScopeTip(x) ? ' data-tip="' + esc(prScopeTip(x)) + '"' : '') + '>' +
|
|
384
|
+
(isPrKey(x.key) ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + (PR_WORD[x.state] ? ' · ' + PR_WORD[x.state] : '') + '</span></button>'
|
|
385
|
+
let h = rest.map(chip).join('')
|
|
386
|
+
if (prs.length <= PR_CHIPS) h += prs.map(chip).join('')
|
|
387
|
+
else {
|
|
388
|
+
const cur = prs.find((x) => x.key === scope)
|
|
389
|
+
h += '<button type="button" data-action="wt-prs" data-id="' + esc(sid) + '" aria-pressed="' + (cur ? 'true' : 'false') + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc(cur ? cur.label + ', pick another of ' + prs.length + ' PRs' : 'Pick one of ' + prs.length + ' PRs') + '"' + (cur && prScopeTip(cur) ? ' data-tip="' + esc(prScopeTip(cur)) + '"' : '') + '>' +
|
|
390
|
+
icon('git-pull-request') + '<span>' + esc(cur ? cur.label + (PR_WORD[cur.state] ? ' · ' + PR_WORD[cur.state] : '') : 'PRs · ' + prs.length) + '</span>' + icon('chevron-down', 'ic wt-seg-chev') + '</button>'
|
|
391
|
+
}
|
|
392
|
+
return '<div class="wt-seg" role="group" aria-label="What to walk through">' + h + '</div>'
|
|
379
393
|
}
|
|
380
394
|
|
|
381
395
|
// 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
|
|
@@ -464,7 +478,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
464
478
|
if (ui.loading) h += '<p class="wt-sum wt-loading" role="status"><span class="wt-spin" aria-hidden="true"></span><span>Reading the change' + (data && data.status === 'ok' ? '' : '…') + '</span><button type="button" class="linkish wt-stop" data-action="wt-stop" data-id="' + esc(sid) + '">Stop</button></p>'
|
|
465
479
|
if (ui.error) h += '<p class="wt-note" role="alert">' + icon('circle-alert') + '<span>' + esc(ui.error) + '</span></p>'
|
|
466
480
|
if (data && !ui.loading && data.status !== 'ok' && data.message) {
|
|
467
|
-
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope
|
|
481
|
+
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && isPrKey(scope) ? 'No changes in this PR yet.' : data.message
|
|
468
482
|
h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
|
|
469
483
|
h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
|
|
470
484
|
// a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
|
|
@@ -473,7 +487,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
473
487
|
if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
|
|
474
488
|
}
|
|
475
489
|
// 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
|
|
476
|
-
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
490
|
+
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (isPrKey(ui.scope) ? 'No walkthrough of this PR yet.' : data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
477
491
|
const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
|
|
478
492
|
if (ok && view === 'story' && !deck) {
|
|
479
493
|
const steps = data.steps || []
|
|
@@ -562,7 +576,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
562
576
|
let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
|
|
563
577
|
// the overview
|
|
564
578
|
const st = data.stats || {}
|
|
565
|
-
const scope = SCOPE_WORDS[ui.scope] || ''
|
|
579
|
+
const scope = SCOPE_WORDS[ui.scope] || (((data.scopes || []).find((x) => x.key === ui.scope) || {}).label) || ''
|
|
566
580
|
const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
|
|
567
581
|
let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
|
|
568
582
|
o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'
|