@leftoff/cli 0.9.18-beta.6 → 0.9.18-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/bin/leftoff.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 +74 -0
- package/ui/app.js +213 -27
- 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,58 @@ 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
|
+
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3463
|
+
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3464
|
+
let i = 0
|
|
3465
|
+
const step = () => {
|
|
3466
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3467
|
+
fitFullViewport()
|
|
3468
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3469
|
+
}
|
|
3470
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3471
|
+
})
|
|
3472
|
+
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3473
|
+
// goes down, whatever iOS reports or doesn't on the way.
|
|
3474
|
+
document.addEventListener('focusout', (e) => {
|
|
3475
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3476
|
+
clearTimeout(lfKb.settle)
|
|
3477
|
+
let i = 0
|
|
3478
|
+
const step = () => {
|
|
3479
|
+
fitFullViewport()
|
|
3480
|
+
if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
|
|
3481
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3482
|
+
else lfKb.scroll = null
|
|
3483
|
+
}
|
|
3484
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3485
|
+
})
|
|
3445
3486
|
function fullAfterRender(sid) {
|
|
3446
3487
|
const box = document.querySelector('.live-box.full')
|
|
3447
3488
|
setFullClass(!!box)
|
|
@@ -3686,11 +3727,40 @@ function composerFor(el) {
|
|
|
3686
3727
|
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
3687
3728
|
return box && !box.classList.contains('off') ? box : null
|
|
3688
3729
|
}
|
|
3730
|
+
// The picker the paperclip opens lives outside the page (0.9.18). A phone's
|
|
3731
|
+
// photo picker covers the board for seconds; coming back is a wake-up (focus
|
|
3732
|
+
// after more than AWAY_MS away) that redraws the page, and iOS hands the
|
|
3733
|
+
// picked photo over only after that, to the <input> it opened from. A picker
|
|
3734
|
+
// inside the composer was gone by then (the redraw made a new one), so its
|
|
3735
|
+
// change event went nowhere and the photo never showed. This one is never
|
|
3736
|
+
// redrawn; the composer's own hidden input stays for drops and tests.
|
|
3737
|
+
let attPicker = null
|
|
3738
|
+
function attachPicker() {
|
|
3739
|
+
if (attPicker && attPicker.isConnected) return attPicker
|
|
3740
|
+
attPicker = document.createElement('input')
|
|
3741
|
+
attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
|
|
3742
|
+
attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
|
|
3743
|
+
attPicker.setAttribute('aria-hidden', 'true')
|
|
3744
|
+
document.body.appendChild(attPicker)
|
|
3745
|
+
return attPicker
|
|
3746
|
+
}
|
|
3747
|
+
function openPicker(sid) {
|
|
3748
|
+
if (!sid) return
|
|
3749
|
+
const pk = attachPicker()
|
|
3750
|
+
pk.dataset.sid = sid
|
|
3751
|
+
pk.value = ''
|
|
3752
|
+
pk.click() // still inside the tap: iOS opens a picker only from a user gesture
|
|
3753
|
+
}
|
|
3754
|
+
function takePicked(inp) {
|
|
3755
|
+
const files = Array.from(inp.files || [])
|
|
3756
|
+
if (!files.length) return
|
|
3757
|
+
inp.value = '' // the same file can be picked again
|
|
3758
|
+
try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
|
|
3759
|
+
}
|
|
3689
3760
|
document.addEventListener('change', (ev) => {
|
|
3690
3761
|
const inp = ev.target
|
|
3691
3762
|
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
3692
|
-
|
|
3693
|
-
inp.value = '' // the same file can be picked again
|
|
3763
|
+
takePicked(inp)
|
|
3694
3764
|
})
|
|
3695
3765
|
document.addEventListener('paste', (ev) => {
|
|
3696
3766
|
const t = ev.target
|
|
@@ -4447,7 +4517,8 @@ function cpHeadLine(cp) {
|
|
|
4447
4517
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4448
4518
|
}
|
|
4449
4519
|
// 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: {} }
|
|
4520
|
+
const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4521
|
+
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4451
4522
|
// The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
|
|
4452
4523
|
function noteSessionDecisions(s) {
|
|
4453
4524
|
const repo = s && s.decisionRepo
|
|
@@ -4460,9 +4531,97 @@ function noteSessionDecisions(s) {
|
|
|
4460
4531
|
decisionsUi.bySession[s.id] = by
|
|
4461
4532
|
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4462
4533
|
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 })
|
|
4534
|
+
if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4464
4535
|
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4465
4536
|
}
|
|
4537
|
+
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
4538
|
+
const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
|
|
4539
|
+
const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4540
|
+
async function viewTestplan(arg, params) {
|
|
4541
|
+
clearTimeout(tpUi.timer)
|
|
4542
|
+
if (arg === 'new') {
|
|
4543
|
+
const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
|
|
4544
|
+
const key = JSON.stringify(q)
|
|
4545
|
+
if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
|
|
4546
|
+
paintTestplanNew()
|
|
4547
|
+
const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
|
|
4548
|
+
try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
|
|
4549
|
+
try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
|
|
4550
|
+
paintTestplanNew()
|
|
4551
|
+
const w = tpUi.nu.preview && tpUi.nu.preview.walking
|
|
4552
|
+
if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
|
|
4553
|
+
return
|
|
4554
|
+
}
|
|
4555
|
+
const machine = params.get('machine') || null
|
|
4556
|
+
if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
|
|
4557
|
+
tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
|
|
4558
|
+
paintTestplan()
|
|
4559
|
+
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)
|
|
4560
|
+
}
|
|
4561
|
+
function paintTestplanNew() {
|
|
4562
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
|
|
4563
|
+
const n = tpUi.nu
|
|
4564
|
+
$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>' : '')
|
|
4565
|
+
}
|
|
4566
|
+
function paintTestplan() {
|
|
4567
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
|
|
4568
|
+
$app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
|
|
4569
|
+
}
|
|
4570
|
+
async function tpCheck(id, check, body) {
|
|
4571
|
+
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)) }
|
|
4572
|
+
}
|
|
4573
|
+
document.addEventListener('click', async (ev) => {
|
|
4574
|
+
const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
|
|
4575
|
+
if (!b || b.tagName === 'FORM' || b.disabled) return
|
|
4576
|
+
const a = b.dataset.action
|
|
4577
|
+
if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
|
|
4578
|
+
if (a === 'tp-run' || a === 'tp-walk') {
|
|
4579
|
+
const n = tpUi.nu
|
|
4580
|
+
if (!n) return
|
|
4581
|
+
const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
|
|
4582
|
+
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 }
|
|
4583
|
+
n.starting = true; paintTestplanNew()
|
|
4584
|
+
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() }
|
|
4585
|
+
return
|
|
4586
|
+
}
|
|
4587
|
+
const id = b.dataset.id, check = b.dataset.check
|
|
4588
|
+
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 }) }
|
|
4589
|
+
if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
|
|
4590
|
+
if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
|
|
4591
|
+
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 }
|
|
4592
|
+
if (a === 'tp-download') {
|
|
4593
|
+
if (!tpUi.plan) return
|
|
4594
|
+
const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
|
|
4595
|
+
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()
|
|
4596
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
4597
|
+
return
|
|
4598
|
+
}
|
|
4599
|
+
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)) } }
|
|
4600
|
+
})
|
|
4601
|
+
document.addEventListener('submit', async (ev) => {
|
|
4602
|
+
const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
|
|
4603
|
+
if (!f) return
|
|
4604
|
+
ev.preventDefault()
|
|
4605
|
+
if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
|
|
4606
|
+
if (f.dataset.action === 'tp-propose-save') {
|
|
4607
|
+
try {
|
|
4608
|
+
const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
|
|
4609
|
+
tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
|
|
4610
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4611
|
+
}
|
|
4612
|
+
})
|
|
4613
|
+
// 1k: the panel's Decided list, drawn again in place (editing one, or after the log loaded)
|
|
4614
|
+
function paintPanelDecided(s) {
|
|
4615
|
+
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4616
|
+
const cp = s && (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
|
|
4617
|
+
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 })
|
|
4618
|
+
}
|
|
4619
|
+
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4620
|
+
function refreshPanelDecisions(s) {
|
|
4621
|
+
if (!s || !s.decisionRepo) return
|
|
4622
|
+
decisionsUi.loadedRepo[s.decisionRepo] = false
|
|
4623
|
+
noteSessionDecisions(s)
|
|
4624
|
+
}
|
|
4466
4625
|
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4467
4626
|
async function viewDecisions(arg) {
|
|
4468
4627
|
const raw = decodeURIComponent(arg || '')
|
|
@@ -4475,13 +4634,20 @@ async function viewDecisions(arg) {
|
|
|
4475
4634
|
}
|
|
4476
4635
|
function paintDecisions() {
|
|
4477
4636
|
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:
|
|
4637
|
+
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
|
|
4479
4638
|
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4480
4639
|
}
|
|
4481
4640
|
async function decisionAction(repo, ref, action, body = {}) {
|
|
4482
4641
|
try {
|
|
4483
|
-
await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4642
|
+
const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4484
4643
|
decisionsUi.loadedRepo = {}
|
|
4644
|
+
// a walkthrough citing it shows its new word (Proposed goes once accepted)
|
|
4645
|
+
const dd = r && r.decision
|
|
4646
|
+
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 = '' }
|
|
4647
|
+
if (wtUi.sid) wtFill(wtSession(wtUi.sid))
|
|
4648
|
+
const dv = document.querySelector('.dv-isdec:not([hidden])')
|
|
4649
|
+
if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
|
|
4650
|
+
if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
|
|
4485
4651
|
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4486
4652
|
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4487
4653
|
} catch (e) { toast(e.message || String(e)) }
|
|
@@ -4492,8 +4658,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
4492
4658
|
const a = btn.dataset.action
|
|
4493
4659
|
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4494
4660
|
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() }
|
|
4661
|
+
if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
|
|
4662
|
+
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4497
4663
|
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4498
4664
|
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
4665
|
if (a === 'dec-show') {
|
|
@@ -4518,10 +4684,14 @@ document.addEventListener('click', (ev) => {
|
|
|
4518
4684
|
if (!b) return
|
|
4519
4685
|
ev.preventDefault()
|
|
4520
4686
|
const s = wtSession(wtUi.sid)
|
|
4521
|
-
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b)
|
|
4687
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
|
|
4522
4688
|
})
|
|
4523
|
-
|
|
4689
|
+
// 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
|
|
4690
|
+
const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4691
|
+
let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
|
|
4692
|
+
async function openDecisionViewer(repo, id, from, ctx = {}) {
|
|
4524
4693
|
if (!repo || !id) return
|
|
4694
|
+
decViewerCtx = { repo, id, from, ctx }
|
|
4525
4695
|
let v
|
|
4526
4696
|
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4527
4697
|
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
@@ -4540,7 +4710,17 @@ async function openDecisionViewer(repo, id, from) {
|
|
|
4540
4710
|
cur = d; guard = 0
|
|
4541
4711
|
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4542
4712
|
}
|
|
4543
|
-
|
|
4713
|
+
// 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
|
|
4714
|
+
// 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
|
|
4715
|
+
const trusted = decisionsTrusted()
|
|
4716
|
+
const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
|
|
4717
|
+
let offer = ''
|
|
4718
|
+
if (d && ctx.departs && ctx.sessionId) {
|
|
4719
|
+
const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
|
|
4720
|
+
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">' +
|
|
4721
|
+
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>'
|
|
4722
|
+
}
|
|
4723
|
+
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
4724
|
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4545
4725
|
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4546
4726
|
refUi.from = from || null
|
|
@@ -4569,7 +4749,9 @@ function paintProjectDecisions() {
|
|
|
4569
4749
|
const box = document.getElementById('projDecisionsBox')
|
|
4570
4750
|
const pd = decisionsUi.project
|
|
4571
4751
|
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) })
|
|
4752
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
|
|
4753
|
+
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4754
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref('project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)), cls: 'proj-review' })
|
|
4573
4755
|
}
|
|
4574
4756
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4575
4757
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
@@ -4598,10 +4780,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
|
4598
4780
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
4599
4781
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
4600
4782
|
// 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: {} }
|
|
4783
|
+
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
4784
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
4603
4785
|
// 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 } }
|
|
4786
|
+
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
4787
|
function paintPrs(d) {
|
|
4606
4788
|
const q = document.getElementById('prs-q')
|
|
4607
4789
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -4750,7 +4932,8 @@ function briefAction(id, sessions, viewer) {
|
|
|
4750
4932
|
|
|
4751
4933
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4752
4934
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
4753
|
-
|
|
4935
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
4936
|
+
const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
4754
4937
|
const out = []
|
|
4755
4938
|
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4756
4939
|
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
@@ -6775,6 +6958,7 @@ async function render() {
|
|
|
6775
6958
|
else if (r.name === 'new') await viewNewSession()
|
|
6776
6959
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
6777
6960
|
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
6961
|
+
else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
|
|
6778
6962
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6779
6963
|
else if (r.name === 'setup') await viewSetup()
|
|
6780
6964
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -8805,6 +8989,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
8805
8989
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
8806
8990
|
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
8807
8991
|
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
8992
|
+
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
|
|
8993
|
+
if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
|
|
8808
8994
|
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
8995
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
8810
8996
|
if (action === 'pr-unlink') {
|
|
@@ -9333,7 +9519,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9333
9519
|
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
9334
9520
|
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
9335
9521
|
if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
|
|
9336
|
-
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.
|
|
9522
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
|
|
9337
9523
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
9338
9524
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
9339
9525
|
if (action === 'att-open') return openAttachment(btn)
|
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
|
}
|