@leftoff/cli 0.9.18-beta.5 → 0.9.18-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +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
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
77
80
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
78
81
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
79
82
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -494,7 +497,7 @@ const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill
494
497
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
495
498
 
496
499
  // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
497
- 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']]
498
501
  function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
499
502
  function cpExtras(cp, paths = null) {
500
503
  let h = ''
@@ -1910,7 +1913,7 @@ function projectRow(list, sel, action, counts, tail = '') {
1910
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>' : ''
1911
1914
  // the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
1912
1915
  return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
1913
- 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>'
1914
1917
  }
1915
1918
  // 0.9.12: the board's own way in to a project with several repos: a "New
1916
1919
  // project" button at the end of the project row (or the repo row before there
@@ -2940,6 +2943,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2940
2943
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2941
2944
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2942
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)
2948
+ if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) noteSessionDecisions(s)
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
2943
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 })
2944
2951
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2945
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>'
@@ -3424,21 +3431,49 @@ function setFullClass(on) {
3424
3431
  applyNav()
3425
3432
  }
3426
3433
  // The window as you see it: on a phone the keyboard takes the bottom of it,
3427
- // 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)
3428
3438
  function fitFullViewport() {
3429
3439
  const vv = window.visualViewport
3430
- const st = document.documentElement.style
3431
- 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 }
3432
3442
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3433
3443
  const strip = document.getElementById('tabStrip')
3434
3444
  const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
3435
3445
  st.setProperty('--lf-bar', bar + 'px')
3436
- if (!vv) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); return }
3437
- st.setProperty('--lf-h', Math.round(vv.height) + 'px')
3438
- st.setProperty('--lf-top', Math.round(vv.offsetTop) + 'px')
3439
- 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)
3440
3454
  }
3441
3455
  if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, fitFullViewport)
3456
+ for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, fitFullViewport)
3457
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
3458
+ document.addEventListener('focusin', (e) => {
3459
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3460
+ clearTimeout(lfKb.settle)
3461
+ if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3462
+ })
3463
+ // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3464
+ // goes down, whatever iOS reports or doesn't on the way.
3465
+ document.addEventListener('focusout', (e) => {
3466
+ if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3467
+ clearTimeout(lfKb.settle)
3468
+ let i = 0
3469
+ const step = () => {
3470
+ fitFullViewport()
3471
+ if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
3472
+ if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
3473
+ else lfKb.scroll = null
3474
+ }
3475
+ lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
3476
+ })
3442
3477
  function fullAfterRender(sid) {
3443
3478
  const box = document.querySelector('.live-box.full')
3444
3479
  setFullClass(!!box)
@@ -4443,6 +4478,243 @@ function cpHeadLine(cp) {
4443
4478
  return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4444
4479
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4445
4480
  }
4481
+ // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4482
+ const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
4483
+ const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
4484
+ // The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
4485
+ function noteSessionDecisions(s) {
4486
+ const repo = s && s.decisionRepo
4487
+ if (!repo || decisionsUi.loadedRepo[repo]) return
4488
+ decisionsUi.loadedRepo[repo] = true
4489
+ api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4490
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
4491
+ const by = {}
4492
+ for (const d of v.decisions || []) if (d.sessionId === s.id) { by[norm(d.text)] = d; if (d.original) by[norm(d.original.text)] = d }
4493
+ decisionsUi.bySession[s.id] = by
4494
+ const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4495
+ const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4496
+ if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
4497
+ }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4498
+ }
4499
+ // ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
4500
+ const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
4501
+ const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4502
+ async function viewTestplan(arg, params) {
4503
+ clearTimeout(tpUi.timer)
4504
+ if (arg === 'new') {
4505
+ const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
4506
+ const key = JSON.stringify(q)
4507
+ if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
4508
+ paintTestplanNew()
4509
+ const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
4510
+ try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
4511
+ try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
4512
+ paintTestplanNew()
4513
+ const w = tpUi.nu.preview && tpUi.nu.preview.walking
4514
+ if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
4515
+ return
4516
+ }
4517
+ const machine = params.get('machine') || null
4518
+ if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
4519
+ tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
4520
+ paintTestplan()
4521
+ if (tpUi.plan && tpUi.plan.status === 'running') tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === arg) viewTestplan(arg, params) }, 1500)
4522
+ }
4523
+ function paintTestplanNew() {
4524
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
4525
+ const n = tpUi.nu
4526
+ $app.innerHTML = testplanNewHtml(n.preview, { minutes: n.minutes, trusted: tpTrusted(), starting: n.starting, recent: n.recent, here: state.health && state.health.machine }, { esc }) + (n.error ? '<div class="banner warn" role="alert"><span>' + esc(n.error) + '</span></div>' : '')
4527
+ }
4528
+ function paintTestplan() {
4529
+ if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
4530
+ $app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
4531
+ }
4532
+ async function tpCheck(id, check, body) {
4533
+ try { tpUi.plan = await api('POST', '/v1/testplans/' + encodeURIComponent(id) + '/check' + (tpUi.machine ? '?machine=' + encodeURIComponent(tpUi.machine) : ''), { check, ...body }); paintTestplan() } catch (e) { toast(e.message || String(e)) }
4534
+ }
4535
+ document.addEventListener('click', async (ev) => {
4536
+ const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
4537
+ if (!b || b.tagName === 'FORM' || b.disabled) return
4538
+ const a = b.dataset.action
4539
+ if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
4540
+ if (a === 'tp-run' || a === 'tp-walk') {
4541
+ const n = tpUi.nu
4542
+ if (!n) return
4543
+ const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
4544
+ if (a === 'tp-walk') { try { await api('POST', '/v1/testplans/walk', body); toast('Walking through them one at a time'); viewTestplan('new', state.route.params) } catch (e) { toast(e.message || String(e)) } return }
4545
+ n.starting = true; paintTestplanNew()
4546
+ try { const r = await api('POST', '/v1/testplans', { ...body, minutes: n.minutes }, { timeoutMs: 90000 }); location.hash = testplanHref(r.id) } catch (e) { n.starting = false; n.error = e.message || String(e); paintTestplanNew() }
4547
+ return
4548
+ }
4549
+ const id = b.dataset.id, check = b.dataset.check
4550
+ if (a === 'tp-tick') { const cur = tpUi.plan && tpUi.plan.ticks && tpUi.plan.ticks[check]; const want = b.dataset.state || null; return tpCheck(id, check, { state: cur && cur.state === want ? null : want }) }
4551
+ if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
4552
+ if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
4553
+ if (a === 'tp-copy') { const md = tpUi.plan && planMarkdown(tpUi.plan); if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4554
+ if (a === 'tp-download') {
4555
+ if (!tpUi.plan) return
4556
+ const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
4557
+ const x = document.createElement('a'); x.href = url; x.download = exportName('Test plan ' + ((tpUi.plan.scope && tpUi.plan.scope.label) || '')); document.body.appendChild(x); x.click(); x.remove()
4558
+ setTimeout(() => URL.revokeObjectURL(url), 4000)
4559
+ return
4560
+ }
4561
+ if (a === 'tp-delete') { if (!confirm('Delete this test plan?')) return; try { await api('DELETE', '/v1/testplans/' + encodeURIComponent(id)); location.hash = '#/prs' } catch (e) { toast(e.message || String(e)) } }
4562
+ })
4563
+ document.addEventListener('submit', async (ev) => {
4564
+ const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
4565
+ if (!f) return
4566
+ ev.preventDefault()
4567
+ if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
4568
+ if (f.dataset.action === 'tp-propose-save') {
4569
+ try {
4570
+ const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
4571
+ tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
4572
+ } catch (e) { toast(e.message || String(e)) }
4573
+ }
4574
+ })
4575
+ // 1k: the panel's Decided list, drawn again in place (editing one, or after the log loaded)
4576
+ function paintPanelDecided(s) {
4577
+ const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4578
+ const cp = s && (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4579
+ if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
4580
+ }
4581
+ // 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
4582
+ function refreshPanelDecisions(s) {
4583
+ if (!s || !s.decisionRepo) return
4584
+ decisionsUi.loadedRepo[s.decisionRepo] = false
4585
+ noteSessionDecisions(s)
4586
+ }
4587
+ // ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
4588
+ async function viewDecisions(arg) {
4589
+ const raw = decodeURIComponent(arg || '')
4590
+ if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
4591
+ decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
4592
+ const want = state.route && state.route.params && state.route.params.get('d')
4593
+ if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
4594
+ paintDecisions()
4595
+ if (want) { const el = document.getElementById('dec-' + want); if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600) } }
4596
+ }
4597
+ function paintDecisions() {
4598
+ if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
4599
+ $app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
4600
+ if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
4601
+ }
4602
+ async function decisionAction(repo, ref, action, body = {}) {
4603
+ try {
4604
+ const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
4605
+ decisionsUi.loadedRepo = {}
4606
+ // a walkthrough citing it shows its new word (Proposed goes once accepted)
4607
+ const dd = r && r.decision
4608
+ if (dd) for (const w of wtUi.data.values()) for (const x of (w && w.decisions) || []) if (x.id === dd.id) { if (dd.status === 'proposed') x.status = 'proposed'; else delete x.status; wtUi.sig = '' }
4609
+ if (wtUi.sid) wtFill(wtSession(wtUi.sid))
4610
+ const dv = document.querySelector('.dv-isdec:not([hidden])')
4611
+ if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
4612
+ if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
4613
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4614
+ else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
4615
+ } catch (e) { toast(e.message || String(e)) }
4616
+ }
4617
+ document.addEventListener('click', async (ev) => {
4618
+ const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
4619
+ if (!btn || btn.tagName === 'FORM') return
4620
+ const a = btn.dataset.action
4621
+ if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
4622
+ if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
4623
+ if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
4624
+ if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
4625
+ if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
4626
+ if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4627
+ if (a === 'dec-show') {
4628
+ const el = document.getElementById('dec-' + btn.dataset.id)
4629
+ if (el) { el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600); return }
4630
+ if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
4631
+ return
4632
+ }
4633
+ if (a === 'dec-store') {
4634
+ const on = btn.dataset.on === 'true'
4635
+ try {
4636
+ await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
4637
+ toast(on ? 'Accepted decisions now go into each repo’s docs/DECISIONS.md' : 'The decision log stays in Leftoff; repo files are left as they are')
4638
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4639
+ else loadProjectDecisions(qaUi.token, true)
4640
+ } catch (e) { toast(e.message || String(e)) }
4641
+ }
4642
+ })
4643
+ // A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
4644
+ document.addEventListener('click', (ev) => {
4645
+ const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
4646
+ if (!b) return
4647
+ ev.preventDefault()
4648
+ const s = wtSession(wtUi.sid)
4649
+ openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
4650
+ })
4651
+ // 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
4652
+ const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
4653
+ let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
4654
+ async function openDecisionViewer(repo, id, from, ctx = {}) {
4655
+ if (!repo || !id) return
4656
+ decViewerCtx = { repo, id, from, ctx }
4657
+ let v
4658
+ try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
4659
+ const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
4660
+ const d = byId.get(id)
4661
+ const el = docViewerEl()
4662
+ el.querySelector('.dv-title').textContent = 'Decision ' + id
4663
+ el.querySelector('.dv-dl').hidden = true
4664
+ el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
4665
+ el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
4666
+ // the chain: what it replaced (oldest first), the decision, what replaced it
4667
+ const chain = []
4668
+ if (d) {
4669
+ const back = []; let cur = d, guard = 0
4670
+ while (cur && cur.supersedes && cur.supersedes.length && guard++ < 20) { const o = byId.get(cur.supersedes[0]); if (!o || back.includes(o)) break; back.unshift(o); cur = o }
4671
+ chain.push(...back, d)
4672
+ cur = d; guard = 0
4673
+ while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
4674
+ }
4675
+ // 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
4676
+ // 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
4677
+ const trusted = decisionsTrusted()
4678
+ const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
4679
+ let offer = ''
4680
+ if (d && ctx.departs && ctx.sessionId) {
4681
+ const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
4682
+ if (own.length) offer = '<section class="dec-offer"><h3>This step departs from ' + esc(d.id) + '</h3><p class="faint">If one of this session’s decisions changes it, link them so ' + esc(d.id) + ' reads as superseded.</p><ul class="dec-tl">' +
4683
+ own.map((x) => decisionItemHtml(x, { esc, byId, may: x.status === 'proposed', review: x.status === 'proposed', edit: false, trusted, replaces: x.status === 'accepted' ? d.id : null })).join('') + '</ul></section>'
4684
+ }
4685
+ el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map(item).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') + offer +
4686
+ '<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
4687
+ el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
4688
+ refUi.from = from || null
4689
+ el.hidden = false
4690
+ document.documentElement.classList.add('dv-open')
4691
+ const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
4692
+ }
4693
+ document.addEventListener('submit', (ev) => {
4694
+ const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
4695
+ if (!f) return
4696
+ ev.preventDefault()
4697
+ const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
4698
+ decisionsUi.editing = null
4699
+ decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4700
+ })
4701
+ // The project page's Decisions section
4702
+ async function loadProjectDecisions(token, force = false) {
4703
+ if (!token) return
4704
+ try {
4705
+ const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
4706
+ decisionsUi.project = { token, v }
4707
+ paintProjectDecisions()
4708
+ } catch { decisionsUi.project = null }
4709
+ }
4710
+ function paintProjectDecisions() {
4711
+ const box = document.getElementById('projDecisionsBox')
4712
+ const pd = decisionsUi.project
4713
+ if (!box || !pd || pd.token !== qaUi.token) return
4714
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
4715
+ const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
4716
+ if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref('project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)), cls: 'proj-review' })
4717
+ }
4446
4718
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4447
4719
  // your note and the files behind More (open or closed as you last left it on this device).
4448
4720
  function cpLatestHtml(cp, wtSid, paths) {
@@ -4470,10 +4742,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
4470
4742
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
4471
4743
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
4472
4744
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
4473
- const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
4745
+ const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {}, picks: new Set() } // testplan Part 3: picks, merged PRs for a test plan
4474
4746
  // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
4475
4747
  // tap repaints from what we have and reads again only when that is old.
4476
- function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
4748
+ function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list, picks: prsUi.picks } }
4477
4749
  function paintPrs(d) {
4478
4750
  const q = document.getElementById('prs-q')
4479
4751
  const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
@@ -4622,7 +4894,8 @@ function briefAction(id, sessions, viewer) {
4622
4894
 
4623
4895
  function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
4624
4896
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
4625
- 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)
4897
+ // testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
4898
+ const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
4626
4899
  const out = []
4627
4900
  let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
4628
4901
  const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
@@ -6646,6 +6919,8 @@ async function render() {
6646
6919
  else if (r.name === 'search') await viewSearch(r.q || '')
6647
6920
  else if (r.name === 'new') await viewNewSession()
6648
6921
  else if (r.name === 'project' && r.arg) await viewProject(r.arg)
6922
+ else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
6923
+ else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
6649
6924
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6650
6925
  else if (r.name === 'setup') await viewSetup()
6651
6926
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6680,6 +6955,7 @@ async function viewProject(token) {
6680
6955
  const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
6681
6956
  qaUi.page = page
6682
6957
  paintProject()
6958
+ if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
6683
6959
  if (fresh) window.scrollTo(0, 0)
6684
6960
  const r = state.route || {}
6685
6961
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6699,6 +6975,11 @@ function paintProject() {
6699
6975
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6700
6976
  $app.innerHTML = out.html
6701
6977
  fillAsk($app, out.keys)
6978
+ if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
6979
+ const at = document.getElementById('projAsks')
6980
+ if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
6981
+ paintProjectDecisions()
6982
+ }
6702
6983
  const ta = document.getElementById('askIn')
6703
6984
  if (ta) { ta.value = state.drafts['ask:' + ((p.scope || {}).key || '')] || ''; fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
6704
6985
  }
@@ -8670,6 +8951,8 @@ document.addEventListener('click', async (ev) => {
8670
8951
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
8671
8952
  if (action === 'pr-expand') return togglePr(btn.dataset.url)
8672
8953
  if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
8954
+ if (action === 'prs-pick') { const u = btn.dataset.url; if (prsUi.picks.has(u)) prsUi.picks.delete(u); else prsUi.picks.add(u); return paintPrsBody() } // testplan Part 3
8955
+ if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
8673
8956
  if (action === 'prs-older') { const d = prsUi.data[prsUi.view]; if (!d) return; d.olderLoading = true; btn.disabled = true; btn.textContent = 'Loading…'; prsUi.done[prsUi.view] = (Number(d.doneShown) || 0) + 100; prsUi.at[prsUi.view] = 0; return render() } // 0.9.12
8674
8957
  if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
8675
8958
  if (action === 'pr-unlink') {
@@ -9538,7 +9821,7 @@ function docViewerEl() {
9538
9821
  document.body.appendChild(el)
9539
9822
  return el
9540
9823
  }
9541
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9824
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9542
9825
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9543
9826
  async function openDoc(sessionId, { path, plan } = {}) {
9544
9827
  if (!sessionId || (!path && !plan)) return
package/ui/ask.js CHANGED
@@ -19,7 +19,7 @@ import { glyphState, glyphHtml } from './sglyph.js' // 0.9.14: the status glyph,
19
19
  export const projectHref = (token) => '#/project/' + encodeURIComponent(String(token || ''))
20
20
  export const threadHref = (id, machine = null) => '#/ask/' + encodeURIComponent(String(id || '')) + (machine ? '?m=' + encodeURIComponent(machine) : '')
21
21
 
22
- const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2})\]/g
22
+ const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2}|dec:D-[0-9a-f]{4,12})\]/g
23
23
 
24
24
  // Where a citation chip goes: a session, a checkpoint on its session page,
25
25
  // a digest day; a commit has no page here (its sha is copied).
@@ -28,15 +28,17 @@ export function chipHref(c) {
28
28
  if (c.kind === 'session' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId)
29
29
  if (c.kind === 'checkpoint' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId) + (c.cpId ? '?cp=' + encodeURIComponent(c.cpId) : '')
30
30
  if (c.kind === 'digest' && c.date) return '#/digest/' + encodeURIComponent(c.date)
31
+ if (c.kind === 'decision' && c.repo && c.id) return '#/decisions/' + String(c.repo).replace(/[^\w:./@-]/g, encodeURIComponent) + '?d=' + encodeURIComponent(c.id) // 2026-11-03
31
32
  return null
32
33
  }
33
34
  export function chipText(c) {
34
35
  if (!c) return ''
35
36
  if (c.kind === 'commit') return (c.short || String(c.sha || '').slice(0, 7)) + (c.label ? ' ' + c.label : '')
36
37
  if (c.kind === 'digest') return 'Digest ' + (c.date || '')
38
+ if (c.kind === 'decision') return (c.id || 'Decision') + (c.label ? ' ' + c.label : '')
37
39
  return c.label || (c.kind === 'checkpoint' ? 'Checkpoint' : 'Session')
38
40
  }
39
- const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper' }
41
+ const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper', decision: 'route' }
40
42
 
41
43
  // One chip. Its words are filled in as text (data-qt).
42
44
  export function chipHtml(c, key, { esc, icon = () => '', n = null } = {}) {
@@ -236,7 +238,7 @@ export function projectHeadHtml(p, { esc, icon = () => '', switcher = false, edi
236
238
  const sc = (p && p.scope) || {}
237
239
  const repos = (sc.repoNames && sc.repoNames.length ? sc.repoNames : sc.repos) || []
238
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(' · ') }
239
- 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
240
242
  // 0.9.12: Edit (the project's own menu: Rename…, Edit repos…, Delete project) where projects can change
241
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 }
242
244
  }