@leftoff/cli 0.9.18-beta.6 → 0.9.18-beta.7
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.js +569 -535
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +9 -5
- package/mod/hooks/register.js +22 -1
- package/mod/skills/walkthrough/SKILL.md +2 -2
- package/package.json +1 -1
- package/ui/app.css +73 -0
- package/ui/app.js +172 -24
- package/ui/ask.js +1 -1
- package/ui/decisions.js +69 -16
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +12 -5
- package/ui/session-catchup.js +18 -2
- package/ui/summary-examples.js +1 -1
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +40 -0
package/ui/app.js
CHANGED
|
@@ -2,6 +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, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
|
|
5
6
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
6
7
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
7
8
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -74,7 +75,8 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
|
|
|
74
75
|
import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
|
|
75
76
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
76
77
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
77
|
-
import {
|
|
78
|
+
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
79
|
+
import { decidedListHtml, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
78
80
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
79
81
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
80
82
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -495,7 +497,7 @@ const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill
|
|
|
495
497
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
496
498
|
|
|
497
499
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
498
|
-
const EXTRA_TITLES = [['decisions', '
|
|
500
|
+
const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
499
501
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
500
502
|
function cpExtras(cp, paths = null) {
|
|
501
503
|
let h = ''
|
|
@@ -1911,7 +1913,7 @@ function projectRow(list, sel, action, counts, tail = '') {
|
|
|
1911
1913
|
const manage = sel && projectsUi.mayEdit && projectsUi.list.some((p) => p.id === sel) ? '<button type="button" class="btn icon-btn more proj-more" data-action="project-manage" data-project="' + esc(sel) + '" aria-label="Project actions" data-tip="Rename, edit repos, delete" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' : ''
|
|
1912
1914
|
// the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
|
|
1913
1915
|
return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
|
|
1914
|
-
chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
|
|
1916
|
+
chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name) + reviewCountHtml(((projectsUi.list.find((p) => p.id === c.id) || {}).decisionsToReview) || 0, { esc, cls: 'pchip-rc' }), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
|
|
1915
1917
|
}
|
|
1916
1918
|
// 0.9.12: the board's own way in to a project with several repos: a "New
|
|
1917
1919
|
// project" button at the end of the project row (or the repo row before there
|
|
@@ -2941,8 +2943,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2941
2943
|
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2942
2944
|
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2943
2945
|
side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
|
|
2946
|
+
side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
|
|
2947
|
+
decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
|
|
2944
2948
|
if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) noteSessionDecisions(s)
|
|
2945
|
-
if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) side += foldHtml({ key: 'cp-decided', title: 'Decided', count: cps[0].decided.length, open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(cps[0].decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null }) }, { esc }) // 2026-11-03 decision memory
|
|
2949
|
+
if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) side += foldHtml({ key: 'cp-decided', title: 'Decided', count: cps[0].decided.length, open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(cps[0].decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc }) // 2026-11-03 decision memory; 1k Accept / Reject / Edit right here
|
|
2946
2950
|
if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
|
|
2947
2951
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2948
2952
|
if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
@@ -3427,21 +3431,49 @@ function setFullClass(on) {
|
|
|
3427
3431
|
applyNav()
|
|
3428
3432
|
}
|
|
3429
3433
|
// The window as you see it: on a phone the keyboard takes the bottom of it,
|
|
3430
|
-
// and the composer stays just above the keyboard.
|
|
3434
|
+
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3435
|
+
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3436
|
+
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3437
|
+
const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
|
|
3431
3438
|
function fitFullViewport() {
|
|
3432
3439
|
const vv = window.visualViewport
|
|
3433
|
-
const
|
|
3434
|
-
if (!
|
|
3440
|
+
const html = document.documentElement, st = html.style
|
|
3441
|
+
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 }
|
|
3435
3442
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3436
3443
|
const strip = document.getElementById('tabStrip')
|
|
3437
3444
|
const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
|
|
3438
3445
|
st.setProperty('--lf-bar', bar + 'px')
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
st.
|
|
3442
|
-
|
|
3446
|
+
const typing = isTypingTarget(document.activeElement)
|
|
3447
|
+
const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
|
|
3448
|
+
if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
|
|
3449
|
+
st.setProperty('--lf-h', fit.h + 'px')
|
|
3450
|
+
st.setProperty('--lf-top', fit.top + 'px')
|
|
3451
|
+
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3452
|
+
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3453
|
+
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3443
3454
|
}
|
|
3444
3455
|
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
|
|
3456
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, fitFullViewport)
|
|
3457
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3458
|
+
document.addEventListener('focusin', (e) => {
|
|
3459
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3460
|
+
clearTimeout(lfKb.settle)
|
|
3461
|
+
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3462
|
+
})
|
|
3463
|
+
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3464
|
+
// goes down, whatever iOS reports or doesn't on the way.
|
|
3465
|
+
document.addEventListener('focusout', (e) => {
|
|
3466
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3467
|
+
clearTimeout(lfKb.settle)
|
|
3468
|
+
let i = 0
|
|
3469
|
+
const step = () => {
|
|
3470
|
+
fitFullViewport()
|
|
3471
|
+
if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
|
|
3472
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3473
|
+
else lfKb.scroll = null
|
|
3474
|
+
}
|
|
3475
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3476
|
+
})
|
|
3445
3477
|
function fullAfterRender(sid) {
|
|
3446
3478
|
const box = document.querySelector('.live-box.full')
|
|
3447
3479
|
setFullClass(!!box)
|
|
@@ -4447,7 +4479,8 @@ function cpHeadLine(cp) {
|
|
|
4447
4479
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4448
4480
|
}
|
|
4449
4481
|
// 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
|
|
4450
|
-
const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {} }
|
|
4482
|
+
const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4483
|
+
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4451
4484
|
// The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
|
|
4452
4485
|
function noteSessionDecisions(s) {
|
|
4453
4486
|
const repo = s && s.decisionRepo
|
|
@@ -4460,9 +4493,97 @@ function noteSessionDecisions(s) {
|
|
|
4460
4493
|
decisionsUi.bySession[s.id] = by
|
|
4461
4494
|
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4462
4495
|
const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
|
|
4463
|
-
if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo })
|
|
4496
|
+
if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4464
4497
|
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4465
4498
|
}
|
|
4499
|
+
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
4500
|
+
const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
|
|
4501
|
+
const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4502
|
+
async function viewTestplan(arg, params) {
|
|
4503
|
+
clearTimeout(tpUi.timer)
|
|
4504
|
+
if (arg === 'new') {
|
|
4505
|
+
const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
|
|
4506
|
+
const key = JSON.stringify(q)
|
|
4507
|
+
if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
|
|
4508
|
+
paintTestplanNew()
|
|
4509
|
+
const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
|
|
4510
|
+
try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
|
|
4511
|
+
try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
|
|
4512
|
+
paintTestplanNew()
|
|
4513
|
+
const w = tpUi.nu.preview && tpUi.nu.preview.walking
|
|
4514
|
+
if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
|
|
4515
|
+
return
|
|
4516
|
+
}
|
|
4517
|
+
const machine = params.get('machine') || null
|
|
4518
|
+
if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
|
|
4519
|
+
tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
|
|
4520
|
+
paintTestplan()
|
|
4521
|
+
if (tpUi.plan && tpUi.plan.status === 'running') tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === arg) viewTestplan(arg, params) }, 1500)
|
|
4522
|
+
}
|
|
4523
|
+
function paintTestplanNew() {
|
|
4524
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
|
|
4525
|
+
const n = tpUi.nu
|
|
4526
|
+
$app.innerHTML = testplanNewHtml(n.preview, { minutes: n.minutes, trusted: tpTrusted(), starting: n.starting, recent: n.recent, here: state.health && state.health.machine }, { esc }) + (n.error ? '<div class="banner warn" role="alert"><span>' + esc(n.error) + '</span></div>' : '')
|
|
4527
|
+
}
|
|
4528
|
+
function paintTestplan() {
|
|
4529
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
|
|
4530
|
+
$app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
|
|
4531
|
+
}
|
|
4532
|
+
async function tpCheck(id, check, body) {
|
|
4533
|
+
try { tpUi.plan = await api('POST', '/v1/testplans/' + encodeURIComponent(id) + '/check' + (tpUi.machine ? '?machine=' + encodeURIComponent(tpUi.machine) : ''), { check, ...body }); paintTestplan() } catch (e) { toast(e.message || String(e)) }
|
|
4534
|
+
}
|
|
4535
|
+
document.addEventListener('click', async (ev) => {
|
|
4536
|
+
const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
|
|
4537
|
+
if (!b || b.tagName === 'FORM' || b.disabled) return
|
|
4538
|
+
const a = b.dataset.action
|
|
4539
|
+
if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
|
|
4540
|
+
if (a === 'tp-run' || a === 'tp-walk') {
|
|
4541
|
+
const n = tpUi.nu
|
|
4542
|
+
if (!n) return
|
|
4543
|
+
const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
|
|
4544
|
+
if (a === 'tp-walk') { try { await api('POST', '/v1/testplans/walk', body); toast('Walking through them one at a time'); viewTestplan('new', state.route.params) } catch (e) { toast(e.message || String(e)) } return }
|
|
4545
|
+
n.starting = true; paintTestplanNew()
|
|
4546
|
+
try { const r = await api('POST', '/v1/testplans', { ...body, minutes: n.minutes }, { timeoutMs: 90000 }); location.hash = testplanHref(r.id) } catch (e) { n.starting = false; n.error = e.message || String(e); paintTestplanNew() }
|
|
4547
|
+
return
|
|
4548
|
+
}
|
|
4549
|
+
const id = b.dataset.id, check = b.dataset.check
|
|
4550
|
+
if (a === 'tp-tick') { const cur = tpUi.plan && tpUi.plan.ticks && tpUi.plan.ticks[check]; const want = b.dataset.state || null; return tpCheck(id, check, { state: cur && cur.state === want ? null : want }) }
|
|
4551
|
+
if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
|
|
4552
|
+
if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
|
|
4553
|
+
if (a === 'tp-copy') { const md = tpUi.plan && planMarkdown(tpUi.plan); if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
|
|
4554
|
+
if (a === 'tp-download') {
|
|
4555
|
+
if (!tpUi.plan) return
|
|
4556
|
+
const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
|
|
4557
|
+
const x = document.createElement('a'); x.href = url; x.download = exportName('Test plan ' + ((tpUi.plan.scope && tpUi.plan.scope.label) || '')); document.body.appendChild(x); x.click(); x.remove()
|
|
4558
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
4559
|
+
return
|
|
4560
|
+
}
|
|
4561
|
+
if (a === 'tp-delete') { if (!confirm('Delete this test plan?')) return; try { await api('DELETE', '/v1/testplans/' + encodeURIComponent(id)); location.hash = '#/prs' } catch (e) { toast(e.message || String(e)) } }
|
|
4562
|
+
})
|
|
4563
|
+
document.addEventListener('submit', async (ev) => {
|
|
4564
|
+
const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
|
|
4565
|
+
if (!f) return
|
|
4566
|
+
ev.preventDefault()
|
|
4567
|
+
if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
|
|
4568
|
+
if (f.dataset.action === 'tp-propose-save') {
|
|
4569
|
+
try {
|
|
4570
|
+
const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
|
|
4571
|
+
tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
|
|
4572
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4573
|
+
}
|
|
4574
|
+
})
|
|
4575
|
+
// 1k: the panel's Decided list, drawn again in place (editing one, or after the log loaded)
|
|
4576
|
+
function paintPanelDecided(s) {
|
|
4577
|
+
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4578
|
+
const cp = s && (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
|
|
4579
|
+
if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4580
|
+
}
|
|
4581
|
+
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4582
|
+
function refreshPanelDecisions(s) {
|
|
4583
|
+
if (!s || !s.decisionRepo) return
|
|
4584
|
+
decisionsUi.loadedRepo[s.decisionRepo] = false
|
|
4585
|
+
noteSessionDecisions(s)
|
|
4586
|
+
}
|
|
4466
4587
|
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4467
4588
|
async function viewDecisions(arg) {
|
|
4468
4589
|
const raw = decodeURIComponent(arg || '')
|
|
@@ -4475,13 +4596,20 @@ async function viewDecisions(arg) {
|
|
|
4475
4596
|
}
|
|
4476
4597
|
function paintDecisions() {
|
|
4477
4598
|
if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
|
|
4478
|
-
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted:
|
|
4599
|
+
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
|
|
4479
4600
|
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4480
4601
|
}
|
|
4481
4602
|
async function decisionAction(repo, ref, action, body = {}) {
|
|
4482
4603
|
try {
|
|
4483
|
-
await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4604
|
+
const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4484
4605
|
decisionsUi.loadedRepo = {}
|
|
4606
|
+
// a walkthrough citing it shows its new word (Proposed goes once accepted)
|
|
4607
|
+
const dd = r && r.decision
|
|
4608
|
+
if (dd) for (const w of wtUi.data.values()) for (const x of (w && w.decisions) || []) if (x.id === dd.id) { if (dd.status === 'proposed') x.status = 'proposed'; else delete x.status; wtUi.sig = '' }
|
|
4609
|
+
if (wtUi.sid) wtFill(wtSession(wtUi.sid))
|
|
4610
|
+
const dv = document.querySelector('.dv-isdec:not([hidden])')
|
|
4611
|
+
if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
|
|
4612
|
+
if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
|
|
4485
4613
|
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4486
4614
|
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4487
4615
|
} catch (e) { toast(e.message || String(e)) }
|
|
@@ -4492,8 +4620,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
4492
4620
|
const a = btn.dataset.action
|
|
4493
4621
|
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4494
4622
|
if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
|
|
4495
|
-
if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; return paintDecisions() }
|
|
4496
|
-
if (a === 'dec-cancel') { decisionsUi.editing = null; return paintDecisions() }
|
|
4623
|
+
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() }
|
|
4624
|
+
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4497
4625
|
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4498
4626
|
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 }
|
|
4499
4627
|
if (a === 'dec-show') {
|
|
@@ -4518,10 +4646,14 @@ document.addEventListener('click', (ev) => {
|
|
|
4518
4646
|
if (!b) return
|
|
4519
4647
|
ev.preventDefault()
|
|
4520
4648
|
const s = wtSession(wtUi.sid)
|
|
4521
|
-
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b)
|
|
4649
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
|
|
4522
4650
|
})
|
|
4523
|
-
|
|
4651
|
+
// 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
|
|
4652
|
+
const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4653
|
+
let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
|
|
4654
|
+
async function openDecisionViewer(repo, id, from, ctx = {}) {
|
|
4524
4655
|
if (!repo || !id) return
|
|
4656
|
+
decViewerCtx = { repo, id, from, ctx }
|
|
4525
4657
|
let v
|
|
4526
4658
|
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4527
4659
|
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
@@ -4540,7 +4672,17 @@ async function openDecisionViewer(repo, id, from) {
|
|
|
4540
4672
|
cur = d; guard = 0
|
|
4541
4673
|
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4542
4674
|
}
|
|
4543
|
-
|
|
4675
|
+
// 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
|
|
4676
|
+
// 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
|
|
4677
|
+
const trusted = decisionsTrusted()
|
|
4678
|
+
const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
|
|
4679
|
+
let offer = ''
|
|
4680
|
+
if (d && ctx.departs && ctx.sessionId) {
|
|
4681
|
+
const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
|
|
4682
|
+
if (own.length) offer = '<section class="dec-offer"><h3>This step departs from ' + esc(d.id) + '</h3><p class="faint">If one of this session’s decisions changes it, link them so ' + esc(d.id) + ' reads as superseded.</p><ul class="dec-tl">' +
|
|
4683
|
+
own.map((x) => decisionItemHtml(x, { esc, byId, may: x.status === 'proposed', review: x.status === 'proposed', edit: false, trusted, replaces: x.status === 'accepted' ? d.id : null })).join('') + '</ul></section>'
|
|
4684
|
+
}
|
|
4685
|
+
el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map(item).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') + offer +
|
|
4544
4686
|
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4545
4687
|
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4546
4688
|
refUi.from = from || null
|
|
@@ -4569,7 +4711,9 @@ function paintProjectDecisions() {
|
|
|
4569
4711
|
const box = document.getElementById('projDecisionsBox')
|
|
4570
4712
|
const pd = decisionsUi.project
|
|
4571
4713
|
if (!box || !pd || pd.token !== qaUi.token) return
|
|
4572
|
-
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
|
|
4714
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
|
|
4715
|
+
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4716
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref('project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)), cls: 'proj-review' })
|
|
4573
4717
|
}
|
|
4574
4718
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4575
4719
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
@@ -4598,10 +4742,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
|
4598
4742
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
4599
4743
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
4600
4744
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
4601
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
|
|
4745
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {}, picks: new Set() } // testplan Part 3: picks, merged PRs for a test plan
|
|
4602
4746
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
4603
4747
|
// tap repaints from what we have and reads again only when that is old.
|
|
4604
|
-
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
|
|
4748
|
+
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list, picks: prsUi.picks } }
|
|
4605
4749
|
function paintPrs(d) {
|
|
4606
4750
|
const q = document.getElementById('prs-q')
|
|
4607
4751
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -4750,7 +4894,8 @@ function briefAction(id, sessions, viewer) {
|
|
|
4750
4894
|
|
|
4751
4895
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4752
4896
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
4753
|
-
|
|
4897
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
4898
|
+
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)
|
|
4754
4899
|
const out = []
|
|
4755
4900
|
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4756
4901
|
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
@@ -6775,6 +6920,7 @@ async function render() {
|
|
|
6775
6920
|
else if (r.name === 'new') await viewNewSession()
|
|
6776
6921
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
6777
6922
|
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
6923
|
+
else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
|
|
6778
6924
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6779
6925
|
else if (r.name === 'setup') await viewSetup()
|
|
6780
6926
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -8805,6 +8951,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
8805
8951
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
8806
8952
|
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
8807
8953
|
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
8954
|
+
if (action === 'prs-pick') { const u = btn.dataset.url; if (prsUi.picks.has(u)) prsUi.picks.delete(u); else prsUi.picks.add(u); return paintPrsBody() } // testplan Part 3
|
|
8955
|
+
if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
|
|
8808
8956
|
if (action === 'prs-older') { const d = prsUi.data[prsUi.view]; if (!d) return; d.olderLoading = true; btn.disabled = true; btn.textContent = 'Loading…'; prsUi.done[prsUi.view] = (Number(d.doneShown) || 0) + 100; prsUi.at[prsUi.view] = 0; return render() } // 0.9.12
|
|
8809
8957
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
8810
8958
|
if (action === 'pr-unlink') {
|
package/ui/ask.js
CHANGED
|
@@ -238,7 +238,7 @@ export function projectHeadHtml(p, { esc, icon = () => '', switcher = false, edi
|
|
|
238
238
|
const sc = (p && p.scope) || {}
|
|
239
239
|
const repos = (sc.repoNames && sc.repoNames.length ? sc.repoNames : sc.repos) || []
|
|
240
240
|
const keys = { 'p-title': (sc.emoji ? sc.emoji + ' ' : '') + scopeName(sc), 'p-sub': [sc.kind === 'project' ? (repos.length ? repos.join(', ') : null) : (sc.sub ? 'folder ' + sc.sub : null), (sc.machines || []).length ? 'on ' + sc.machines.join(', ') : null, p && p.sessionCount != null ? p.sessionCount + (p.sessionCount === 1 ? ' session' : ' sessions') : null].filter(Boolean).join(' · ') }
|
|
241
|
-
return { html: '<div class="pagehead proj-head"><div class="proj-head-main"><div class="proj-kicker">' + icon('folder') + '<span>Project</span></div>' + (switcher ? '<h1 class="proj-title"><button type="button" class="proj-switch" data-action="project-ask-menu" aria-haspopup="menu" aria-expanded="false" data-tip="Ask about another project or repo"><span data-qt="p-title"></span>' + icon('chevron-down') + '</button></h1>' : '<h1 data-qt="p-title"></h1>') + '<div class="sub" data-qt="p-sub"></div></div>' +
|
|
241
|
+
return { html: '<div class="pagehead proj-head"><div class="proj-head-main"><div class="proj-kicker">' + icon('folder') + '<span>Project</span></div>' + (switcher ? '<h1 class="proj-title"><button type="button" class="proj-switch" data-action="project-ask-menu" aria-haspopup="menu" aria-expanded="false" data-tip="Ask about another project or repo"><span data-qt="p-title"></span>' + icon('chevron-down') + '</button></h1>' : '<h1 data-qt="p-title"></h1>') + '<div class="sub" data-qt="p-sub"></div><div class="proj-review-slot" id="projDecReview"></div></div>' + // 1k: decisions to review, filled once the log loads
|
|
242
242
|
// 0.9.12: Edit (the project's own menu: Rename…, Edit repos…, Delete project) where projects can change
|
|
243
243
|
(edit && sc.kind === 'project' ? '<button type="button" class="btn proj-edit" data-action="project-manage" data-project="' + esc(edit) + '" aria-haspopup="menu" aria-expanded="false" data-tip="Rename, edit repos, delete">' + icon('pencil') + '<span>Edit</span></button>' : '') + '</div>', keys }
|
|
244
244
|
}
|
package/ui/decisions.js
CHANGED
|
@@ -12,22 +12,44 @@ export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejecte
|
|
|
12
12
|
|
|
13
13
|
// A checkpoint's decisions, under the newest checkpoint in the session panel.
|
|
14
14
|
// log: the session's decisions from its repo's log, when loaded ({ <text norm>: { id, status } }).
|
|
15
|
-
|
|
15
|
+
// 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
|
|
16
|
+
// device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
|
|
17
|
+
export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true } = {}) {
|
|
16
18
|
const list = Array.isArray(decided) ? decided : []
|
|
17
19
|
if (!list.length) return ''
|
|
18
20
|
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
19
21
|
const rows = list.map((d) => {
|
|
20
22
|
const hit = log && log[norm(d.text)]
|
|
21
|
-
const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '">' + STATUS_SHAPE[hit.status] + ' ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
|
|
22
|
-
return '<li class="dec-row"
|
|
23
|
+
const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[hit.status] + '</span> ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
|
|
24
|
+
if (hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
|
|
25
|
+
const rp = (hit && (hit.repo || repoId)) || ''
|
|
26
|
+
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>'
|
|
27
|
+
const acts = hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
|
|
28
|
+
'<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
|
|
29
|
+
return '<li class="dec-row"' + (hit ? ' id="decp-' + esc(hit.id) + '"' : '') + '>' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(hit && hit.edited ? hit.text : d.text) + '</span>' + st +
|
|
23
30
|
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
24
31
|
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
25
|
-
'<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div
|
|
32
|
+
'<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div>' + acts + '</li>'
|
|
26
33
|
}).join('')
|
|
27
34
|
const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
28
35
|
return '<ul class="dec-list">' + rows + '</ul>' + link
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
// 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
|
|
39
|
+
// words), or wasn't given them because Claude Code skipped Leftoff's SessionStart (the why in the tip).
|
|
40
|
+
export function decisionsGivenHtml(s, { esc = escD, when = (iso) => String(iso || '').slice(0, 16).replace('T', ' ') } = {}) {
|
|
41
|
+
if (!s) return ''
|
|
42
|
+
const g = s.decisionsGiven
|
|
43
|
+
if (g && g.n > 0) {
|
|
44
|
+
const words = g.via === 'command' ? 'Given ' + g.n + ' decision' + (g.n === 1 ? '' : 's') + ' with /cp-decisions' : 'Started with ' + g.n + ' decision' + (g.n === 1 ? '' : 's')
|
|
45
|
+
const tip = (g.via === 'command' ? 'You ran /cp-decisions' : 'Leftoff added the repo’s accepted decisions when the session started') + (g.at ? ' · ' + when(g.at) : '') + '. Only accepted decisions are given.'
|
|
46
|
+
return '<p class="dec-given" data-tip="' + esc(tip) + '"><span class="dec-given-i" aria-hidden="true">●</span> ' + esc(words) + '</p>'
|
|
47
|
+
}
|
|
48
|
+
const m = s.decisionsMissed
|
|
49
|
+
if (m && m.n > 0) return '<p class="dec-given dec-missed" data-tip="' + esc(m.tip || '') + '"><span class="dec-given-i" aria-hidden="true">◌</span> ' + esc(m.label || ('Not given its repo’s ' + m.n + ' decisions')) + ' <span class="dec-given-how">· type <code>/cp-decisions</code></span></p>'
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
// ------------------------------------------------------------ the decision log (timeline)
|
|
32
54
|
|
|
33
55
|
// A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
|
|
@@ -50,6 +72,9 @@ export function timelineRows(decisions, { all = false } = {}) {
|
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
function sessionBit(d, esc) {
|
|
75
|
+
// testplan Part 3: proposed from a failed check of a test plan: the plan is where it came from
|
|
76
|
+
const tp = /^testplan:(tp_[0-9a-f]{16})#(c\d+)$/.exec(String(d.checkpointId || ''))
|
|
77
|
+
if (tp) return '<a class="linkish dec-sess" href="#/testplan/' + esc(tp[1]) + '">From a test plan (check ' + esc(tp[2].slice(1)) + ')</a>'
|
|
53
78
|
const s = d.session
|
|
54
79
|
if (!s || !s.id) return ''
|
|
55
80
|
const title = s.title || 'its session'
|
|
@@ -72,16 +97,25 @@ function editForm(d, esc) {
|
|
|
72
97
|
'<div class="dec-acts"><button type="submit" class="btn primary">Save</button><button type="button" class="btn" data-action="dec-cancel" data-id="' + esc(d.id) + '">Cancel</button></div></form>'
|
|
73
98
|
}
|
|
74
99
|
|
|
100
|
+
// 1h: POST /v1/decisions/action is trustedOnly: on a paired device without Trust my paired devices the
|
|
101
|
+
// buttons show, disabled, with this tip, rather than failing on a click.
|
|
102
|
+
export const DEC_TRUST_TIP = 'Changing the decision log from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
|
|
103
|
+
const offAttrs = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(DEC_TRUST_TIP) + '"' : '')
|
|
104
|
+
|
|
75
105
|
// One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
|
|
76
|
-
|
|
106
|
+
// trusted (1h): false on a device that may look but not change the log. edit: false hides Edit (the viewer).
|
|
107
|
+
// replaces (1g): an id this one may be confirmed to replace (a walkthrough step departs from it).
|
|
108
|
+
export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false, trusted = true, edit = true, replaces = null } = {}) {
|
|
77
109
|
const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
|
|
78
110
|
const head = '<div class="dec-h"><span class="dec-date">' + esc(day(d.createdAt)) + '</span><span class="dec-id">' + esc(d.id) + '</span>' + repo + statusBit(d, esc) + '</div>'
|
|
79
111
|
if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
|
|
80
112
|
const sug = (d.suggests || []).map((id) => { const o = byId.get(id); return o ? '<div class="dec-sug"><span>Changes <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>: ' + esc(o.text) + '</span>' +
|
|
81
|
-
(may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '">Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">Not related</button></span>' : '') + '</div>' : '' }).join('')
|
|
82
|
-
const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' + label + '</button>'
|
|
83
|
-
const
|
|
84
|
-
|
|
113
|
+
(may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Not related</button></span>' : '') + '</div>' : '' }).join('')
|
|
114
|
+
const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
|
|
115
|
+
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>' : ''
|
|
116
|
+
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>' : ''
|
|
117
|
+
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>'
|
|
118
|
+
: '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
|
|
85
119
|
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
86
120
|
'<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>' +
|
|
87
121
|
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
@@ -91,10 +125,10 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
|
|
|
91
125
|
}
|
|
92
126
|
|
|
93
127
|
// The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
|
|
94
|
-
export function storageChoiceHtml(project, { esc = escD, may = true } = {}) {
|
|
128
|
+
export function storageChoiceHtml(project, { esc = escD, may = true, trusted = true } = {}) {
|
|
95
129
|
if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
|
|
96
130
|
const on = !!project.inRepo
|
|
97
|
-
const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (may ? '' : ' disabled') + '>' + label + '</button>'
|
|
131
|
+
const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (trusted === false ? offAttrs(false, esc) : may ? '' : ' disabled') + '>' + label + '</button>'
|
|
98
132
|
return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
|
|
99
133
|
'<div class="sask-scope dec-store-opts" role="group" aria-label="Keep decision log in this repo">' + opt(false, 'Leftoff only') + opt(true, 'Also in each repo') + '</div>' +
|
|
100
134
|
'<p class="faint">' + (on ? 'Accepted decisions are written to ' + esc(project.file || 'docs/DECISIONS.md') + ' in each repo of ' + esc(project.name) + ', between Leftoff’s markers, on a Mac with a checkout. Never staged or committed: you commit it or not.'
|
|
@@ -109,25 +143,44 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
|
109
143
|
const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
|
|
110
144
|
const sc = (v && v.scope) || {}
|
|
111
145
|
const name = sc.kind === 'project' ? sc.name : sc.label
|
|
112
|
-
const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi }
|
|
146
|
+
const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi, trusted: ui.trusted !== false }
|
|
113
147
|
let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
|
|
114
148
|
h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
|
|
115
149
|
(multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
|
|
116
|
-
if (
|
|
150
|
+
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)
|
|
151
|
+
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>'
|
|
117
152
|
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>'
|
|
118
153
|
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>' +
|
|
119
|
-
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button
|
|
154
|
+
'<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
|
|
120
155
|
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
121
156
|
(rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">' + (review.length ? 'Nothing accepted yet.' : 'No decisions yet. They’re proposed here when a checkpoint sees one: a question you answered, a plan you approved, a correction.') + '</p>') + '</section>'
|
|
122
157
|
return '<div class="proj dec-page">' + h + '</div>'
|
|
123
158
|
}
|
|
124
159
|
|
|
160
|
+
// testplan Part 3: the way in to a test plan of the scope's merged PRs (a project, or a repo's log)
|
|
161
|
+
export function testplanLinkHtml(sc, { esc = escD, cls = 'btn' } = {}) {
|
|
162
|
+
if (!sc) return ''
|
|
163
|
+
const token = sc.kind === 'project' ? sc.id : sc.label || sc.id
|
|
164
|
+
if (!token) return ''
|
|
165
|
+
return '<a class="' + cls + ' tp-open" href="#/testplan/new?scope=' + esc(encodeURIComponent(token)) + '" data-tip="A short manual test plan for the PRs merged lately">Test plan</a>'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 1k: a quiet count of decisions waiting to be reviewed: a shape plus a number (and the words for a screen reader and the tip), never a colored badge alone.
|
|
169
|
+
export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
|
|
170
|
+
if (!(n > 0)) return ''
|
|
171
|
+
const words = n + ' decision' + (n === 1 ? '' : 's') + ' to review'
|
|
172
|
+
const inner = '<span aria-hidden="true">◌</span> ' + n
|
|
173
|
+
return href ? '<a class="dec-rc ' + cls + '" href="' + esc(href) + '" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '<span class="dec-rc-w"> to review</span></a>'
|
|
174
|
+
: '<span class="dec-rc ' + cls + '" role="img" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '</span>'
|
|
175
|
+
}
|
|
176
|
+
|
|
125
177
|
// The project page's short Decisions section: counts, the storage choice, the way in.
|
|
126
|
-
export function projectDecisionsHtml(v, { esc = escD, may = true, scopeArg = '' } = {}) {
|
|
178
|
+
export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
|
|
127
179
|
if (!v) return ''
|
|
128
180
|
const list = v.decisions || []
|
|
129
181
|
const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
|
|
130
182
|
return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
|
|
131
183
|
'<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
|
|
132
|
-
|
|
184
|
+
'<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
|
|
185
|
+
storageChoiceHtml(v.project, { esc, may, trusted }) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
|
|
133
186
|
}
|
package/ui/prs.js
CHANGED
|
@@ -182,8 +182,11 @@ function sessionLinks(r, opts) {
|
|
|
182
182
|
|
|
183
183
|
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
184
184
|
// (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
|
|
185
|
-
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
|
|
185
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null, picks = null } = {}) {
|
|
186
186
|
const p = r.pr || {}
|
|
187
|
+
// testplan Part 3: a merged PR can be picked for a test plan (a box plus the word, the state in aria-pressed)
|
|
188
|
+
const picked = !!(picks && picks.has(p.url))
|
|
189
|
+
const pick = picks && p.state === 'merged' ? '<button type="button" class="pr-pick" data-action="prs-pick" data-url="' + esc(p.url) + '" aria-pressed="' + picked + '" data-tip="' + (picked ? 'Picked for a test plan' : 'Pick for a test plan') + '"><span aria-hidden="true">' + (picked ? '☑' : '☐') + '</span><span class="pr-pick-w">' + (picked ? 'Picked' : 'Pick') + '</span></button>' : ''
|
|
187
190
|
const url = safeUrl(p.url)
|
|
188
191
|
// 0.8.9: what the checks and review chips don't already say (a conflict, your review)
|
|
189
192
|
const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
|
|
@@ -195,7 +198,7 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
|
|
|
195
198
|
: [p.repo, p.branch, when].filter(Boolean)
|
|
196
199
|
const why = whyText(r)
|
|
197
200
|
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
198
|
-
return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"
|
|
201
|
+
return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
|
|
199
202
|
'<div class="pr-line1">' + line1 + '</div>' +
|
|
200
203
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
201
204
|
'<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
|
|
@@ -411,12 +414,19 @@ export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr
|
|
|
411
414
|
function doneGroupHtml(g, { open, rowOpts }) {
|
|
412
415
|
return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
|
|
413
416
|
}
|
|
414
|
-
|
|
417
|
+
// testplan Part 3: what's picked, and the way to a test plan of exactly those
|
|
418
|
+
export function prPicksBarHtml(picks) {
|
|
419
|
+
const n = picks ? picks.size : 0
|
|
420
|
+
if (!n) return ''
|
|
421
|
+
const href = '#/testplan/new?prs=' + [...picks].map(encodeURIComponent).join(',')
|
|
422
|
+
return '<div class="prs-picks" role="status"><span><span aria-hidden="true">☑</span> ' + n + ' merged PR' + (n === 1 ? '' : 's') + ' picked</span><a class="btn primary" href="' + esc(href) + '">Test plan for these</a><button type="button" class="linkish" data-action="prs-pick-clear">Clear</button></div>'
|
|
423
|
+
}
|
|
424
|
+
export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [], picks = null } = {}) {
|
|
415
425
|
const f = { repos: [], state: null, q: '', project: null, ...filter }
|
|
416
426
|
if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
|
|
417
427
|
const all = prRows(d)
|
|
418
428
|
const me = d.me || null
|
|
419
|
-
let h = prsCutHtml(d) // 0.9.12: first, so a phone sees it
|
|
429
|
+
let h = prsCutHtml(d) + prPicksBarHtml(picks) // 0.9.12: first, so a phone sees it
|
|
420
430
|
if (all.length) {
|
|
421
431
|
// 0.9.0: projects first (when you have any), then the repos in the picked one
|
|
422
432
|
if ((projects || []).length) {
|
|
@@ -449,7 +459,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
449
459
|
const rows = filterPrRows(all, f, { me, projects })
|
|
450
460
|
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
451
461
|
const showMachine = !!d.multiMachine
|
|
452
|
-
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
462
|
+
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null, picks: view === 'mine' ? picks : null })
|
|
453
463
|
if (!all.length && d.tooBig) {
|
|
454
464
|
h += '<div class="empty">Too many PRs to load at once. <button type="button" class="linkish" data-action="prs-refresh">Try again</button></div>'
|
|
455
465
|
} else if (!all.length) {
|