@leftoff/cli 0.9.18-beta.4 → 0.9.18-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/bin/leftoff.js +601 -557
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +163 -7
- package/mod/hooks/register.js +144 -6
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +96 -0
- package/ui/app.js +154 -2
- package/ui/ask.js +4 -2
- package/ui/decisions.js +133 -0
- package/ui/hook-fix.js +109 -0
- package/ui/semdiff.js +23 -4
package/ui/app.css
CHANGED
|
@@ -5396,3 +5396,99 @@ code .file-ref-off { font-size: inherit; }
|
|
|
5396
5396
|
.sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
|
|
5397
5397
|
.fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
|
|
5398
5398
|
.fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
|
|
5399
|
+
/* 2026-11-03 which hooks were skipped and why: the same note, its words cut short on a narrow header (the whole in its tip) */
|
|
5400
|
+
.sub .s-hooks-skip { max-width: min(420px, 100%); min-width: 0; }
|
|
5401
|
+
.sub .s-hooks-skip .s-hooks-t { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
5402
|
+
|
|
5403
|
+
/* 2026-11-03 "Let me answer prompts here" (ui/hook-fix.js): the hooks-skipped note's way out. Centered; a bottom sheet on a phone. */
|
|
5404
|
+
.sub .s-hooks-fix { margin-left: 6px; min-height: 44px; padding: 0 10px; border: 1px solid var(--btn-line); border-radius: 8px; background: transparent; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
5405
|
+
@media (pointer: fine) { .sub .s-hooks-fix { min-height: 32px; padding: 0 9px; } }
|
|
5406
|
+
.sub .s-hooks-fix:hover { background: var(--btn-hover); }
|
|
5407
|
+
.sub .s-hooks-fix:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5408
|
+
.fs-side.is-scrolled .fs-head .sub > .s-hooks-fix { display: none; }
|
|
5409
|
+
.sub .s-hooks-applied .s-hooks-ring { border-style: dotted; }
|
|
5410
|
+
.hf-back { position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 16px; }
|
|
5411
|
+
.hf-modal { width: min(560px, 100%); max-height: calc(100dvh - 32px); overflow: auto; background: var(--card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--toast-shadow); padding: 16px 18px 14px; color: var(--text2); }
|
|
5412
|
+
.hf-grab { display: none; }
|
|
5413
|
+
.hf-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
|
|
5414
|
+
.hf-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text); text-transform: none; letter-spacing: normal; }
|
|
5415
|
+
.hf-p { margin: 8px 0; font-size: 13px; line-height: 1.5; }
|
|
5416
|
+
.hf-quiet { color: var(--muted); font-size: 12.5px; }
|
|
5417
|
+
.hf-file code, .hf-p code { font-size: 12px; overflow-wrap: anywhere; }
|
|
5418
|
+
.hf-file { margin: 4px 0; }
|
|
5419
|
+
.hf-code { margin: 6px 0; padding: 10px 12px; background: var(--field); border: 1px solid var(--line); border-radius: 8px; font-size: 12px; line-height: 1.45; overflow: auto; white-space: pre; color: var(--text); user-select: all; }
|
|
5420
|
+
.hf-mark { display: inline-block; width: 9px; height: 9px; margin-right: 7px; vertical-align: 0; box-sizing: border-box; }
|
|
5421
|
+
.hf-mark-ok { border-radius: 50%; background: var(--green); }
|
|
5422
|
+
.hf-mark-admin { border-radius: 2px; border: 1.5px solid var(--yellow); transform: rotate(45deg); }
|
|
5423
|
+
.hf-mark-off { border-radius: 50%; border: 1.5px dashed var(--muted); }
|
|
5424
|
+
.hf-ign { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
|
|
5425
|
+
.hf-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
5426
|
+
.hf-acts .btn, .hf-x { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
5427
|
+
@media (pointer: fine) { .hf-acts .btn, .hf-x { min-height: 32px; min-width: 32px; } }
|
|
5428
|
+
.hf-modal .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5429
|
+
@media (max-width: 640px) {
|
|
5430
|
+
.hf-back { align-items: flex-end; padding: 0; }
|
|
5431
|
+
.hf-modal { width: 100%; max-height: 88dvh; border-radius: var(--radius) var(--radius) 0 0; border-bottom: none; padding: 8px 16px calc(14px + env(safe-area-inset-bottom)); }
|
|
5432
|
+
.hf-grab { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 2px auto 8px; }
|
|
5433
|
+
.hf-acts { justify-content: stretch; }
|
|
5434
|
+
.hf-acts .btn { flex: 1 1 auto; }
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5437
|
+
/* 2026-11-03 decision memory */
|
|
5438
|
+
.dec-list { list-style: none; margin: 0; padding: 0 0 6px; display: grid; gap: 10px; }
|
|
5439
|
+
.dec-row { font-size: 13.5px; line-height: 1.45; color: var(--text); }
|
|
5440
|
+
.dec-id { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
|
|
5441
|
+
.dec-why, .dec-alt, .dec-src { font-size: 12.5px; color: var(--text2); margin-top: 2px; }
|
|
5442
|
+
.dec-src { color: var(--muted, var(--text2)); }
|
|
5443
|
+
.dec-st { margin-left: 6px; font-size: 12px; color: var(--text2); white-space: nowrap; }
|
|
5444
|
+
.dec-open { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; }
|
|
5445
|
+
|
|
5446
|
+
.dec-page .dec-head h1 { margin: 4px 0 2px; }
|
|
5447
|
+
.dec-tl { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 0; }
|
|
5448
|
+
.dec-item { position: relative; padding: 12px 0 12px 18px; border-left: 2px solid var(--line); margin-left: 6px; }
|
|
5449
|
+
.dec-item::before { content: ''; position: absolute; left: -6px; top: 17px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--text2); box-sizing: border-box; }
|
|
5450
|
+
.dec-item.dec-accepted::before { background: var(--text2); }
|
|
5451
|
+
.dec-item.dec-superseded, .dec-item.dec-rejected { opacity: .78; }
|
|
5452
|
+
.dec-item.dec-superseded::before { border-color: var(--line-strong, var(--line)); }
|
|
5453
|
+
.dec-item.dec-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; }
|
|
5454
|
+
.dec-h { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12.5px; color: var(--text2); }
|
|
5455
|
+
.dec-date { font-variant-numeric: tabular-nums; }
|
|
5456
|
+
.dec-repo { padding: 1px 6px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; }
|
|
5457
|
+
.dec-item .dec-st { margin-left: 0; }
|
|
5458
|
+
.dec-item .dec-text { font-size: 15px; line-height: 1.45; margin-top: 4px; color: var(--text); overflow-wrap: anywhere; }
|
|
5459
|
+
.dec-meta { font-size: 12.5px; color: var(--text2); margin-top: 4px; }
|
|
5460
|
+
.dec-chains { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 12.5px; color: var(--text2); }
|
|
5461
|
+
.dec-chip { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; min-height: 24px; cursor: pointer; position: relative; }
|
|
5462
|
+
.dec-chip::after { content: ''; position: absolute; inset: -10px -4px; }
|
|
5463
|
+
.dec-chip:hover { border-color: var(--text2); }
|
|
5464
|
+
.dec-sug { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; font-size: 13px; }
|
|
5465
|
+
.dec-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
5466
|
+
.dec-acts .btn, .dec-acts .linkish { min-height: 44px; }
|
|
5467
|
+
.dec-acts-quiet { margin-top: 0; }
|
|
5468
|
+
.dec-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 4px; }
|
|
5469
|
+
.dec-ctl .sask-opt, .dec-ctl .btn, .dec-store-opts .sask-opt { min-height: 44px; }
|
|
5470
|
+
.dec-store-h { font-weight: 600; margin-bottom: 6px; }
|
|
5471
|
+
.dec-store-opts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
|
5472
|
+
.dec-store p { margin: 8px 0 0; max-width: 62ch; }
|
|
5473
|
+
.dec-edit { display: grid; gap: 8px; margin-top: 8px; max-width: 640px; }
|
|
5474
|
+
.dec-edit label { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
|
|
5475
|
+
.dec-in { font: inherit; font-size: 15px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); min-height: 44px; box-sizing: border-box; width: 100%; }
|
|
5476
|
+
@media (pointer: coarse) { .dec-in { font-size: 16px; } }
|
|
5477
|
+
.dec-in:focus-visible { outline: 2px solid var(--accent, var(--text2)); outline-offset: 1px; }
|
|
5478
|
+
.dec-flash { background: color-mix(in srgb, var(--text2) 10%, transparent); transition: background .6s; }
|
|
5479
|
+
@media (prefers-reduced-motion: reduce) { .dec-flash { transition: none; } }
|
|
5480
|
+
.dec-proj p { margin: 4px 0 10px; }
|
|
5481
|
+
|
|
5482
|
+
.wt-decs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
|
|
5483
|
+
.wt-dec { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 4px 8px; font-weight: 400; }
|
|
5484
|
+
@media (pointer: coarse) { .wt-dec { min-height: 44px; } }
|
|
5485
|
+
.wt-dec .wt-dec-id { font-weight: 600; }
|
|
5486
|
+
.wt-dec .wt-dec-t { font: 12.5px/1.3 var(--sans, system-ui, sans-serif); color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42ch; }
|
|
5487
|
+
.wt-dec-gone { border-style: dashed; }
|
|
5488
|
+
.wt-dec-w { font: 12px/1 var(--sans, system-ui, sans-serif); color: var(--text2); }
|
|
5489
|
+
.dec-chainview { margin-top: 4px; }
|
|
5490
|
+
|
|
5491
|
+
.dv-isdec .dv-dl { display: none !important; }
|
|
5492
|
+
.dec-ctl .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: transparent; color: var(--text2); cursor: pointer; }
|
|
5493
|
+
.dec-ctl .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); }
|
|
5494
|
+
@media (min-width: 700px) { .dv-isdec .dv-sheet { max-width: 760px; height: auto; max-height: 80vh; margin-left: auto; margin-right: auto; } }
|
package/ui/app.js
CHANGED
|
@@ -39,6 +39,7 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
|
|
|
39
39
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
40
40
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
41
41
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
42
|
+
import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
|
|
42
43
|
import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
|
|
43
44
|
import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
|
|
44
45
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
|
|
@@ -73,6 +74,7 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
|
|
|
73
74
|
import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
|
|
74
75
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
75
76
|
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
|
|
76
78
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
77
79
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
78
80
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -471,7 +473,23 @@ const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st
|
|
|
471
473
|
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
472
474
|
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
473
475
|
const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
|
|
474
|
-
|
|
476
|
+
// 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
|
|
477
|
+
// sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
|
|
478
|
+
const hooksQuietHtml = (s) => {
|
|
479
|
+
if (!s || s.endedAt) return ''
|
|
480
|
+
const k = s.hooksSkipped
|
|
481
|
+
if (k && k.label) return '<span class="s-hooks s-hooks-skip' + (k.applied ? ' s-hooks-applied' : '') + '" data-tip="' + esc(k.tip || k.label) + '"><span class="s-hooks-ring" aria-hidden="true"></span><span class="s-hooks-t">' + esc(k.label) + '</span></span>' + hooksFixButtonHtml(s, { esc }) // 2026-11-03 applied: "restart Claude Code to apply", no button
|
|
482
|
+
return s.hooksQuiet ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : ''
|
|
483
|
+
}
|
|
484
|
+
// the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
|
|
485
|
+
document.addEventListener('click', (e) => {
|
|
486
|
+
const b = e.target.closest && e.target.closest('[data-hookfix]')
|
|
487
|
+
if (!b) return
|
|
488
|
+
e.preventDefault()
|
|
489
|
+
const sid = b.dataset.hookfix
|
|
490
|
+
const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
|
|
491
|
+
openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
|
|
492
|
+
})
|
|
475
493
|
const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
|
|
476
494
|
|
|
477
495
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
@@ -2923,6 +2941,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2923
2941
|
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2924
2942
|
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2925
2943
|
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>'
|
|
2944
|
+
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
|
|
2926
2946
|
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 })
|
|
2927
2947
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2928
2948
|
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>'
|
|
@@ -4426,6 +4446,131 @@ function cpHeadLine(cp) {
|
|
|
4426
4446
|
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>' +
|
|
4427
4447
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4428
4448
|
}
|
|
4449
|
+
// 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: {} }
|
|
4451
|
+
// The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
|
|
4452
|
+
function noteSessionDecisions(s) {
|
|
4453
|
+
const repo = s && s.decisionRepo
|
|
4454
|
+
if (!repo || decisionsUi.loadedRepo[repo]) return
|
|
4455
|
+
decisionsUi.loadedRepo[repo] = true
|
|
4456
|
+
api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
|
|
4457
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
4458
|
+
const by = {}
|
|
4459
|
+
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 }
|
|
4460
|
+
decisionsUi.bySession[s.id] = by
|
|
4461
|
+
const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
|
|
4462
|
+
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 })
|
|
4464
|
+
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4465
|
+
}
|
|
4466
|
+
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4467
|
+
async function viewDecisions(arg) {
|
|
4468
|
+
const raw = decodeURIComponent(arg || '')
|
|
4469
|
+
if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
|
|
4470
|
+
decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
|
|
4471
|
+
const want = state.route && state.route.params && state.route.params.get('d')
|
|
4472
|
+
if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
|
|
4473
|
+
paintDecisions()
|
|
4474
|
+
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) } }
|
|
4475
|
+
}
|
|
4476
|
+
function paintDecisions() {
|
|
4477
|
+
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 })
|
|
4479
|
+
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4480
|
+
}
|
|
4481
|
+
async function decisionAction(repo, ref, action, body = {}) {
|
|
4482
|
+
try {
|
|
4483
|
+
await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4484
|
+
decisionsUi.loadedRepo = {}
|
|
4485
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4486
|
+
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4487
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4488
|
+
}
|
|
4489
|
+
document.addEventListener('click', async (ev) => {
|
|
4490
|
+
const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
|
|
4491
|
+
if (!btn || btn.tagName === 'FORM') return
|
|
4492
|
+
const a = btn.dataset.action
|
|
4493
|
+
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4494
|
+
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() }
|
|
4497
|
+
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4498
|
+
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
|
+
if (a === 'dec-show') {
|
|
4500
|
+
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
4501
|
+
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 }
|
|
4502
|
+
if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
|
|
4503
|
+
return
|
|
4504
|
+
}
|
|
4505
|
+
if (a === 'dec-store') {
|
|
4506
|
+
const on = btn.dataset.on === 'true'
|
|
4507
|
+
try {
|
|
4508
|
+
await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
|
|
4509
|
+
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')
|
|
4510
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4511
|
+
else loadProjectDecisions(qaUi.token, true)
|
|
4512
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4513
|
+
}
|
|
4514
|
+
})
|
|
4515
|
+
// A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
|
|
4516
|
+
document.addEventListener('click', (ev) => {
|
|
4517
|
+
const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
|
|
4518
|
+
if (!b) return
|
|
4519
|
+
ev.preventDefault()
|
|
4520
|
+
const s = wtSession(wtUi.sid)
|
|
4521
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b)
|
|
4522
|
+
})
|
|
4523
|
+
async function openDecisionViewer(repo, id, from) {
|
|
4524
|
+
if (!repo || !id) return
|
|
4525
|
+
let v
|
|
4526
|
+
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4527
|
+
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
4528
|
+
const d = byId.get(id)
|
|
4529
|
+
const el = docViewerEl()
|
|
4530
|
+
el.querySelector('.dv-title').textContent = 'Decision ' + id
|
|
4531
|
+
el.querySelector('.dv-dl').hidden = true
|
|
4532
|
+
el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
|
|
4533
|
+
el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
|
|
4534
|
+
// the chain: what it replaced (oldest first), the decision, what replaced it
|
|
4535
|
+
const chain = []
|
|
4536
|
+
if (d) {
|
|
4537
|
+
const back = []; let cur = d, guard = 0
|
|
4538
|
+
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 }
|
|
4539
|
+
chain.push(...back, d)
|
|
4540
|
+
cur = d; guard = 0
|
|
4541
|
+
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4542
|
+
}
|
|
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>') +
|
|
4544
|
+
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4545
|
+
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4546
|
+
refUi.from = from || null
|
|
4547
|
+
el.hidden = false
|
|
4548
|
+
document.documentElement.classList.add('dv-open')
|
|
4549
|
+
const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
|
|
4550
|
+
}
|
|
4551
|
+
document.addEventListener('submit', (ev) => {
|
|
4552
|
+
const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
|
|
4553
|
+
if (!f) return
|
|
4554
|
+
ev.preventDefault()
|
|
4555
|
+
const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
|
|
4556
|
+
decisionsUi.editing = null
|
|
4557
|
+
decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
|
|
4558
|
+
})
|
|
4559
|
+
// The project page's Decisions section
|
|
4560
|
+
async function loadProjectDecisions(token, force = false) {
|
|
4561
|
+
if (!token) return
|
|
4562
|
+
try {
|
|
4563
|
+
const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
|
|
4564
|
+
decisionsUi.project = { token, v }
|
|
4565
|
+
paintProjectDecisions()
|
|
4566
|
+
} catch { decisionsUi.project = null }
|
|
4567
|
+
}
|
|
4568
|
+
function paintProjectDecisions() {
|
|
4569
|
+
const box = document.getElementById('projDecisionsBox')
|
|
4570
|
+
const pd = decisionsUi.project
|
|
4571
|
+
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) })
|
|
4573
|
+
}
|
|
4429
4574
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4430
4575
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
4431
4576
|
function cpLatestHtml(cp, wtSid, paths) {
|
|
@@ -6629,6 +6774,7 @@ async function render() {
|
|
|
6629
6774
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
6630
6775
|
else if (r.name === 'new') await viewNewSession()
|
|
6631
6776
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
6777
|
+
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
6632
6778
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6633
6779
|
else if (r.name === 'setup') await viewSetup()
|
|
6634
6780
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -6663,6 +6809,7 @@ async function viewProject(token) {
|
|
|
6663
6809
|
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
|
|
6664
6810
|
qaUi.page = page
|
|
6665
6811
|
paintProject()
|
|
6812
|
+
if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
|
|
6666
6813
|
if (fresh) window.scrollTo(0, 0)
|
|
6667
6814
|
const r = state.route || {}
|
|
6668
6815
|
if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
|
|
@@ -6682,6 +6829,11 @@ function paintProject() {
|
|
|
6682
6829
|
const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
6683
6830
|
$app.innerHTML = out.html
|
|
6684
6831
|
fillAsk($app, out.keys)
|
|
6832
|
+
if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
|
|
6833
|
+
const at = document.getElementById('projAsks')
|
|
6834
|
+
if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
|
|
6835
|
+
paintProjectDecisions()
|
|
6836
|
+
}
|
|
6685
6837
|
const ta = document.getElementById('askIn')
|
|
6686
6838
|
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 {} } }
|
|
6687
6839
|
}
|
|
@@ -9521,7 +9673,7 @@ function docViewerEl() {
|
|
|
9521
9673
|
document.body.appendChild(el)
|
|
9522
9674
|
return el
|
|
9523
9675
|
}
|
|
9524
|
-
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') }
|
|
9676
|
+
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') }
|
|
9525
9677
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
9526
9678
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
9527
9679
|
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 } = {}) {
|
package/ui/decisions.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Decision memory (2026-11-03): pure builders for the board. A checkpoint's
|
|
2
|
+
// Decided list (the session panel), a repo's or a project's decision log (the
|
|
3
|
+
// timeline), and the decision chips a walkthrough step cites. app.js wires
|
|
4
|
+
// them; unit-tested in test/unit/decisions-ui-1103.test.mjs.
|
|
5
|
+
|
|
6
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
7
|
+
const day = (iso) => String(iso || '').slice(0, 10)
|
|
8
|
+
export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', correction: 'Your correction', agent: 'Agent chose' }
|
|
9
|
+
// Status as a shape and a word, never color alone.
|
|
10
|
+
export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
|
|
11
|
+
export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
|
|
12
|
+
|
|
13
|
+
// A checkpoint's decisions, under the newest checkpoint in the session panel.
|
|
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 } = {}) {
|
|
16
|
+
const list = Array.isArray(decided) ? decided : []
|
|
17
|
+
if (!list.length) return ''
|
|
18
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
19
|
+
const rows = list.map((d) => {
|
|
20
|
+
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
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
24
|
+
(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>'
|
|
26
|
+
}).join('')
|
|
27
|
+
const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
|
|
28
|
+
return '<ul class="dec-list">' + rows + '</ul>' + link
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------ the decision log (timeline)
|
|
32
|
+
|
|
33
|
+
// A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
|
|
34
|
+
export function decisionScope(arg) {
|
|
35
|
+
const a = String(arg || '')
|
|
36
|
+
if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
|
|
37
|
+
return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
|
|
38
|
+
}
|
|
39
|
+
export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
|
|
40
|
+
|
|
41
|
+
// What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
|
|
42
|
+
// (superseded and rejected too). Proposed ones are always above, to review.
|
|
43
|
+
export function timelineRows(decisions, { all = false } = {}) {
|
|
44
|
+
const list = Array.isArray(decisions) ? decisions : []
|
|
45
|
+
return {
|
|
46
|
+
review: list.filter((d) => d.status === 'proposed'),
|
|
47
|
+
rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
|
|
48
|
+
hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionBit(d, esc) {
|
|
53
|
+
const s = d.session
|
|
54
|
+
if (!s || !s.id) return ''
|
|
55
|
+
const title = s.title || 'its session'
|
|
56
|
+
return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
|
|
57
|
+
(s.pr && s.pr.url && /^https:\/\//.test(s.pr.url) ? ' · <a class="linkish dec-pr" href="' + esc(s.pr.url) + '" target="_blank" rel="noopener">PR' + (s.pr.number ? ' #' + esc(s.pr.number) : '') + '</a>' : '')
|
|
58
|
+
}
|
|
59
|
+
function chainBit(d, byId, esc) {
|
|
60
|
+
const out = []
|
|
61
|
+
for (const id of d.supersedes || []) { const o = byId.get(id); out.push('<span class="dec-chain">Replaces <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>' + (o ? ' (' + esc(day(o.createdAt)) + ')' : '') + '</span>') }
|
|
62
|
+
if (d.supersededBy) out.push('<span class="dec-chain">Superseded by <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(d.supersededBy) + '">' + esc(d.supersededBy) + '</button> on ' + esc(day(d.supersededAt)) + '</span>')
|
|
63
|
+
return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
|
|
64
|
+
}
|
|
65
|
+
function statusBit(d, esc) {
|
|
66
|
+
return '<span class="dec-st dec-st-' + esc(d.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>'
|
|
67
|
+
}
|
|
68
|
+
function editForm(d, esc) {
|
|
69
|
+
return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
|
|
70
|
+
'<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
|
|
71
|
+
'<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
|
|
72
|
+
'<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
|
+
}
|
|
74
|
+
|
|
75
|
+
// 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 } = {}) {
|
|
77
|
+
const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
|
|
78
|
+
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
|
+
if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
|
|
80
|
+
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>'
|
|
85
|
+
return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
|
|
86
|
+
'<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
|
+
(d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
|
|
88
|
+
(d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
|
|
89
|
+
chainBit(d, byId, esc) + sug +
|
|
90
|
+
'<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 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 } = {}) {
|
|
95
|
+
if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
|
|
96
|
+
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>'
|
|
98
|
+
return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
|
|
99
|
+
'<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
|
+
'<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.'
|
|
101
|
+
: 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The whole page. v: GET /v1/decisions. ui: { all, editing, may }
|
|
105
|
+
export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
|
|
106
|
+
const list = (v && v.decisions) || []
|
|
107
|
+
const byId = new Map(list.map((d) => [d.id, d]))
|
|
108
|
+
const multi = ((v && v.repos) || []).length > 1
|
|
109
|
+
const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
|
|
110
|
+
const sc = (v && v.scope) || {}
|
|
111
|
+
const name = sc.kind === 'project' ? sc.name : sc.label
|
|
112
|
+
const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi }
|
|
113
|
+
let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
|
|
114
|
+
h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
|
|
115
|
+
(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>'
|
|
117
|
+
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
|
+
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>'
|
|
120
|
+
h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
|
|
121
|
+
(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
|
+
return '<div class="proj dec-page">' + h + '</div>'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The project page's short Decisions section: counts, the storage choice, the way in.
|
|
126
|
+
export function projectDecisionsHtml(v, { esc = escD, may = true, scopeArg = '' } = {}) {
|
|
127
|
+
if (!v) return ''
|
|
128
|
+
const list = v.decisions || []
|
|
129
|
+
const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
|
|
130
|
+
return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
|
|
131
|
+
'<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>'
|
|
133
|
+
}
|
package/ui/hook-fix.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 2026-11-03 "Let me answer prompts here": the hooks-skipped note's way out.
|
|
2
|
+
// Claude Code's Security default runs ahead of the plugins you install (Team or
|
|
3
|
+
// Enterprise, managed settings, a managed MCP file) and sends Stop and
|
|
4
|
+
// permission prompts on past Leftoff. Claude Code ignores appendPlugins in
|
|
5
|
+
// your own ~/.claude/settings.json while it's seated (2.1.317), so the sheet
|
|
6
|
+
// shows the managed-settings lines and where they go, with Copy; Leftoff
|
|
7
|
+
// writes none of it. The one change it can make: take Leftoff's own entry
|
|
8
|
+
// back out of your settings when Claude Code ignores it there (an explicit
|
|
9
|
+
// Remove, this Mac's own browser or a trusted paired device, never on a
|
|
10
|
+
// managed Mac). Another Mac's session: run leftoff doctor --fix there.
|
|
11
|
+
// Centered on a desktop, a bottom sheet on a phone.
|
|
12
|
+
|
|
13
|
+
export function hooksFixButtonHtml(s, { esc }) {
|
|
14
|
+
const k = s && s.hooksSkipped
|
|
15
|
+
if (!k || !k.label || k.applied || s.endedAt) return ''
|
|
16
|
+
return '<button type="button" class="s-hooks-fix" data-hookfix="' + esc(s.id) + '" data-tip="Why Leftoff can’t answer this session’s prompts from the board, and the settings line that lets it">Let me answer prompts here</button>'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let open = null
|
|
20
|
+
|
|
21
|
+
export function closeHookFix() {
|
|
22
|
+
if (!open) return
|
|
23
|
+
const { back, from, onKey, sid } = open
|
|
24
|
+
open = null
|
|
25
|
+
document.removeEventListener('keydown', onKey, true)
|
|
26
|
+
back.remove()
|
|
27
|
+
document.documentElement.classList.remove('hf-open')
|
|
28
|
+
// the header may have been redrawn while the sheet was up: its button for this session now
|
|
29
|
+
const to = from && from.isConnected ? from : sid ? document.querySelector('[data-hookfix="' + CSS.escape(sid) + '"]') : null
|
|
30
|
+
if (to) try { to.focus({ preventScroll: true }) } catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const copyText = async (t) => { try { await navigator.clipboard.writeText(t); return true } catch { return false } }
|
|
34
|
+
|
|
35
|
+
// st: GET /v1/hook-order. -> the sheet's body
|
|
36
|
+
export function hookFixBodyHtml(st, { esc, session = null, thisMachine = null } = {}) {
|
|
37
|
+
if (session && session.isLocal === false) {
|
|
38
|
+
const mac = session.machine || 'that Mac'
|
|
39
|
+
return '<p class="hf-p">This session runs on <b>' + esc(mac) + '</b>, and the settings that decide the plugin order are that Mac’s. Run this there; it says what to add, and why:</p>' +
|
|
40
|
+
'<div class="hf-code-row"><pre class="hf-code hf-cmd">leftoff doctor --fix</pre></div>' +
|
|
41
|
+
'<div class="hf-acts"><button type="button" class="btn hf-copy" data-copy="leftoff doctor --fix">Copy</button><button type="button" class="btn primary hf-close">Close</button></div>'
|
|
42
|
+
}
|
|
43
|
+
if (!st || st.error) return '<p class="hf-p">Couldn’t read this Mac’s Claude Code settings' + (st && st.error ? ' (' + esc(st.error) + ')' : '') + '. <code>leftoff doctor --fix</code> in a terminal says the same.</p><div class="hf-acts"><button type="button" class="btn primary hf-close">Close</button></div>'
|
|
44
|
+
let h = ''
|
|
45
|
+
if (!st.seated) {
|
|
46
|
+
h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span>Nothing here puts a plugin ahead of Leftoff’s hooks' + (thisMachine ? ' on ' + esc(thisMachine) : '') + ' right now. A session that started before a change may still show the note; restart Claude Code there.</p>'
|
|
47
|
+
} else if (st.placed) {
|
|
48
|
+
h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span><b>Managed settings already seat Leftoff</b> right after Claude Code’s Security default. Restart Claude Code sessions that started before that, and their prompts come here.</p>'
|
|
49
|
+
} else {
|
|
50
|
+
h += '<p class="hf-p">' + esc(st.words) + '</p>'
|
|
51
|
+
h += '<p class="hf-p hf-who"><span class="hf-mark hf-mark-admin" aria-hidden="true"></span>' + (st.managed ? 'Ask whoever manages Claude Code here to ' : 'An admin (on your own Mac, that’s you, with sudo) can ') + (st.snippet.merge ? 'merge this into' : 'put this in') + ':</p>'
|
|
52
|
+
h += '<div class="hf-file"><code>' + esc(st.snippet.file) + '</code></div>'
|
|
53
|
+
h += '<pre class="hf-code">' + esc(st.snippet.json) + '</pre>'
|
|
54
|
+
h += '<p class="hf-p hf-quiet">' + esc(st.snippet.note) + '. Then restart Claude Code. Leftoff never writes managed settings.</p>'
|
|
55
|
+
}
|
|
56
|
+
if (st.ignoredEntry) {
|
|
57
|
+
h += '<div class="hf-ign"><p class="hf-p"><span class="hf-mark hf-mark-off" aria-hidden="true"></span>Your <code>' + esc(st.ignoredEntry.file) + '</code> lists Leftoff in ' + esc(st.ignoredEntry.keys.join(' and ')) + ', which Claude Code ignores here. Removing it changes nothing in Claude Code.</p>' +
|
|
58
|
+
(st.canUnfix ? '<p class="hf-p hf-quiet">Remove takes out only Leftoff’s entry and keeps a backup in ~/.config/checkpoints/backups.</p>' : '<p class="hf-p hf-quiet">This Mac has managed settings, so Leftoff changes nothing in ~/.claude; delete the entry by hand if you like.</p>') + '</div>'
|
|
59
|
+
}
|
|
60
|
+
h += '<div class="hf-acts">' +
|
|
61
|
+
(st.ignoredEntry && st.canUnfix ? '<button type="button" class="btn hf-unfix">Remove the ignored entry</button>' : '') +
|
|
62
|
+
(st.seated && !st.placed ? '<button type="button" class="btn hf-copy" data-copy="' + esc(st.snippet.json) + '">Copy</button>' : '') +
|
|
63
|
+
'<button type="button" class="btn primary hf-close">' + (st.seated && !st.placed ? 'Done' : 'Close') + '</button></div>'
|
|
64
|
+
return h
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function openHookFix({ session, from = null, api, esc, toast = () => {}, thisMachine = null } = {}) {
|
|
68
|
+
closeHookFix()
|
|
69
|
+
const back = document.createElement('div')
|
|
70
|
+
back.className = 'hf-back'
|
|
71
|
+
back.innerHTML = '<div class="hf-modal" role="dialog" aria-modal="true" aria-labelledby="hfTitle"><div class="hf-grab" aria-hidden="true"></div>' +
|
|
72
|
+
'<div class="hf-head"><h2 id="hfTitle">Let Leftoff answer prompts here</h2><button type="button" class="btn x hf-x" aria-label="Close">✕</button></div>' +
|
|
73
|
+
'<div class="hf-body"><p class="hf-p hf-quiet">Reading this Mac’s Claude Code settings…</p></div></div>'
|
|
74
|
+
document.body.appendChild(back)
|
|
75
|
+
document.documentElement.classList.add('hf-open')
|
|
76
|
+
const onKey = (e) => {
|
|
77
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeHookFix(); return }
|
|
78
|
+
if (e.key === 'Tab') { // keep focus inside
|
|
79
|
+
const f = [...back.querySelectorAll('button:not([disabled])')]
|
|
80
|
+
if (!f.length) return
|
|
81
|
+
const i = f.indexOf(document.activeElement)
|
|
82
|
+
if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus() }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
open = { back, from, onKey, sid: session && session.id }
|
|
86
|
+
document.addEventListener('keydown', onKey, true)
|
|
87
|
+
const body = back.querySelector('.hf-body')
|
|
88
|
+
const paint = (st) => {
|
|
89
|
+
body.innerHTML = hookFixBodyHtml(st, { esc, session, thisMachine })
|
|
90
|
+
const first = back.querySelector('.hf-copy, .hf-close')
|
|
91
|
+
if (first) try { first.focus({ preventScroll: true }) } catch {}
|
|
92
|
+
}
|
|
93
|
+
back.addEventListener('click', async (e) => {
|
|
94
|
+
if (e.target === back || e.target.closest('.hf-x, .hf-close')) { closeHookFix(); return }
|
|
95
|
+
const c = e.target.closest('.hf-copy')
|
|
96
|
+
if (c) { const ok = await copyText(c.dataset.copy || ''); c.textContent = ok ? 'Copied' : 'Select and copy'; setTimeout(() => { if (c.isConnected) c.textContent = 'Copy' }, 1800); return }
|
|
97
|
+
const u = e.target.closest('.hf-unfix')
|
|
98
|
+
if (u) {
|
|
99
|
+
u.disabled = true; u.textContent = 'Removing…'
|
|
100
|
+
try {
|
|
101
|
+
const r = await api('POST', '/v1/hook-order/unfix', { yes: true })
|
|
102
|
+
paint(r.state)
|
|
103
|
+
toast(r.wrote ? 'Removed Leftoff’s entry from ' + r.file + (r.backup ? ' (backup kept)' : '') + '. Restart open Claude Code sessions.' : 'Nothing to remove.', 5000)
|
|
104
|
+
} catch (err) { u.disabled = false; u.textContent = 'Remove the ignored entry'; toast((err && err.message) || 'Couldn’t change it.', 5000) }
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
if (session && session.isLocal === false) { paint(null); return }
|
|
108
|
+
try { paint(await api('GET', '/v1/hook-order?fresh=1')) } catch (err) { paint({ error: (err && err.message) || 'no answer' }) }
|
|
109
|
+
}
|