@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/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 { decidedListHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
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', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
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 st = document.documentElement.style
3434
- if (!document.documentElement.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); return }
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
- if (!vv) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); return }
3440
- st.setProperty('--lf-h', Math.round(vv.height) + 'px')
3441
- st.setProperty('--lf-top', Math.round(vv.offsetTop) + 'px')
3442
- document.documentElement.classList.toggle('lf-kb', window.innerHeight - vv.height > 120) // the keyboard is up: no home-indicator gap under the composer
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
- addFiles(inp.dataset.sid, inp.files)
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: state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed) }, { esc })
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
- async function openDecisionViewer(repo, id, from) {
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
- el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map((x) => decisionItemHtml(x, { esc, byId, may: false })).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') +
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
- const inline = (t) => esc(t).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)
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.click(); return }
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
- export function decidedListHtml(decided, { esc = escD, log = null, repoId = null } = {}) {
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">' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(d.text) + '</span>' + st +
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></li>'
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
- export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false } = {}) {
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 acts = !may ? '' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button></div>'
84
- : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : '<button type="button" class="linkish" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button>') + '</div>'
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 (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false && ui.trusted !== false }) + '</section>'
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></div>'
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
- storageChoiceHtml(v.project, { esc, may }) + '</section>'
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
  }