@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/CHANGELOG.md +12 -1
- package/bin/leftoff.js +599 -537
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +46 -2
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +132 -0
- package/ui/app.js +296 -13
- package/ui/ask.js +5 -3
- package/ui/decisions.js +186 -0
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +31 -5
- package/ui/session-catchup.js +18 -2
- package/ui/summary-examples.js +1 -1
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +40 -0
package/ui/app.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
+
import { fullViewportFit, isTypingTarget, SETTLE_AFTER_BLUR_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes
|
|
5
6
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
6
7
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
7
8
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -74,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', '
|
|
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
|
|
3431
|
-
if (!
|
|
3440
|
+
const html = document.documentElement, st = html.style
|
|
3441
|
+
if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
|
|
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
|
-
|
|
3437
|
-
|
|
3438
|
-
st.
|
|
3439
|
-
|
|
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
|
-
|
|
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
|
}
|