@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.10
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 +39 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +767 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- 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 +186 -5
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +234 -2
- package/ui/app.js +502 -51
- package/ui/ask.js +5 -3
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +235 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +20 -3
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/summary-examples.js +2 -2
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +59 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
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, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
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
|
|
@@ -11,7 +12,7 @@ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
|
|
|
11
12
|
import { changedKeys } from './settings-dirty.js'
|
|
12
13
|
import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
|
|
13
14
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
14
|
-
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
15
|
+
import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
15
16
|
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
16
17
|
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
17
18
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
@@ -39,13 +40,15 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
|
|
|
39
40
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
40
41
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
41
42
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
43
|
+
import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
|
|
42
44
|
import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
|
|
43
45
|
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
46
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
|
|
45
47
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
46
48
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
47
49
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
48
|
-
import {
|
|
50
|
+
import { cutTitle } from './session-title.js'
|
|
51
|
+
import { sessionTitle, sessionWhere, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
|
|
49
52
|
import { statusView, elapsedText } from './live-status.js'
|
|
50
53
|
import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
|
|
51
54
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
@@ -54,7 +57,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
54
57
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
55
58
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
56
59
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
57
|
-
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT } from './semdiff.js' // 0.9.17 Walk me through it
|
|
60
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
58
61
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
59
62
|
import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
60
63
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
@@ -73,6 +76,9 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
|
|
|
73
76
|
import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
|
|
74
77
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
75
78
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
79
|
+
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
80
|
+
import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
81
|
+
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
76
82
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
77
83
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
78
84
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -468,15 +474,38 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
|
|
|
468
474
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
469
475
|
// 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
|
|
470
476
|
const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
|
|
477
|
+
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
478
|
+
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
479
|
+
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.'
|
|
480
|
+
// 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
|
|
481
|
+
// sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
|
|
482
|
+
const hooksQuietHtml = (s) => {
|
|
483
|
+
if (!s || s.endedAt) return ''
|
|
484
|
+
const k = s.hooksSkipped
|
|
485
|
+
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
|
|
486
|
+
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>' : ''
|
|
487
|
+
}
|
|
488
|
+
// the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
|
|
489
|
+
document.addEventListener('click', (e) => {
|
|
490
|
+
const b = e.target.closest && e.target.closest('[data-hookfix]')
|
|
491
|
+
if (!b) return
|
|
492
|
+
e.preventDefault()
|
|
493
|
+
const sid = b.dataset.hookfix
|
|
494
|
+
const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
|
|
495
|
+
openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
|
|
496
|
+
})
|
|
471
497
|
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>' }
|
|
472
498
|
|
|
473
499
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
474
500
|
|
|
475
501
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
476
|
-
const EXTRA_TITLES = [['decisions', '
|
|
502
|
+
const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
477
503
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
478
|
-
|
|
479
|
-
|
|
504
|
+
// decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
|
|
505
|
+
const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
|
|
506
|
+
const decidedSecHtml = (cp, log) => '<div class="cpx-sec cpx-decided" data-cp="' + esc(cp.id || '') + '"><div class="cpx-h">Decided</div>' + decidedListHtml(cp.decided, { esc, log, readOnly: true }) + '</div>'
|
|
507
|
+
function cpExtras(cp, paths = null, decided = null) {
|
|
508
|
+
let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
|
|
480
509
|
for (const [k, title] of EXTRA_TITLES) {
|
|
481
510
|
const v = cp[k]
|
|
482
511
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
@@ -495,11 +524,11 @@ function cpExtras(cp, paths = null) {
|
|
|
495
524
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
496
525
|
// part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
|
|
497
526
|
// paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
|
|
498
|
-
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
|
|
527
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
|
|
499
528
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
500
529
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
501
530
|
const you = /^YOU:/i.test(cp.next || '')
|
|
502
|
-
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
531
|
+
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
|
|
503
532
|
const dd = (t, cls = '') => {
|
|
504
533
|
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
505
534
|
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
@@ -518,7 +547,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
518
547
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
519
548
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · ' + (cp.generator.by === 'heuristic' ? '' : 'by ') + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) : '') + '</div>' : '') +
|
|
520
549
|
'</dl>' +
|
|
521
|
-
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp
|
|
550
|
+
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + esc(cpFoldSummary(cp, { decided: decidedHere(cp, decided) })) + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths, decided) + '</div></details>' : '')
|
|
522
551
|
}
|
|
523
552
|
|
|
524
553
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -1104,7 +1133,11 @@ function card(s) {
|
|
|
1104
1133
|
// own node is kept between the page's redraws (the live poll repaints the page
|
|
1105
1134
|
// every few seconds) so the scroll, the drawn code and the open groups stay.
|
|
1106
1135
|
// Mark reviewed is the session's own record (session.md, like a pin).
|
|
1107
|
-
|
|
1136
|
+
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1137
|
+
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1138
|
+
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1139
|
+
function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
|
|
1140
|
+
function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
|
|
1108
1141
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1109
1142
|
function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
|
|
1110
1143
|
function wtRestore(sid) {
|
|
@@ -1135,14 +1168,27 @@ async function wtCheck(sid) {
|
|
|
1135
1168
|
if (wtUi.checking.has(k)) return
|
|
1136
1169
|
wtUi.checking.add(k)
|
|
1137
1170
|
try {
|
|
1138
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1171
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
|
|
1139
1172
|
const had = wtUi.data.get(k)
|
|
1140
1173
|
if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
|
|
1174
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1141
1175
|
wtUi.data.set(k, r)
|
|
1142
1176
|
if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
|
|
1143
1177
|
}
|
|
1144
1178
|
} catch {} finally { wtUi.checking.delete(k) }
|
|
1145
1179
|
}
|
|
1180
|
+
// 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
|
|
1181
|
+
// it again, never a model run on its own: peek), its Files view and What it did show the other side at once
|
|
1182
|
+
function setFileScope(v, { id = null } = {}) {
|
|
1183
|
+
const want = v === 'folder' ? 'folder' : 'session'
|
|
1184
|
+
if (fileScope.get() === want) return
|
|
1185
|
+
fileScope.set(want)
|
|
1186
|
+
wtUi.filter = 'all'; wtUi.picks = []
|
|
1187
|
+
for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
|
|
1188
|
+
for (const [k, w] of widUi) { if (w.open && w.diff) loadWid(k); else if (w.diff) Object.assign(w, { tools: null, diff: null, files: {} }) }
|
|
1189
|
+
if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
|
|
1190
|
+
else if (id) repaintSession(id)
|
|
1191
|
+
}
|
|
1146
1192
|
function wtClose() {
|
|
1147
1193
|
const sid = wtUi.sid
|
|
1148
1194
|
document.documentElement.classList.remove('wt-open')
|
|
@@ -1165,7 +1211,7 @@ async function wtSizeFetch(sid) {
|
|
|
1165
1211
|
e.pending = true; wtSize.set(sid, e)
|
|
1166
1212
|
const sc = wtUi.scope.get(sid)
|
|
1167
1213
|
let data = e.data
|
|
1168
|
-
try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
|
|
1214
|
+
try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc + filesQ('&') : filesQ('?'))) } catch { data = e.data || null } // an older Mac: no line
|
|
1169
1215
|
wtSize.set(sid, { at: Date.now(), data, pending: false })
|
|
1170
1216
|
for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
|
|
1171
1217
|
slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
|
|
@@ -1190,7 +1236,7 @@ let loPop = null
|
|
|
1190
1236
|
function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
|
|
1191
1237
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1192
1238
|
// walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
|
|
1193
|
-
async function wtLoad(sid, { fresh = false, update = false } = {}) {
|
|
1239
|
+
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1194
1240
|
const k = wtKey(sid)
|
|
1195
1241
|
const had = wtUi.data.get(k)
|
|
1196
1242
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
@@ -1198,7 +1244,8 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
|
|
|
1198
1244
|
wtUi.loading.add(k); wtUi.error.delete(k)
|
|
1199
1245
|
wtFill(wtSession(sid))
|
|
1200
1246
|
try {
|
|
1201
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1247
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
|
|
1248
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1202
1249
|
wtUi.data.set(k, r)
|
|
1203
1250
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1204
1251
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1323,18 +1370,21 @@ function wtRevNext() {
|
|
|
1323
1370
|
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1324
1371
|
wtSlideGo(st.to)
|
|
1325
1372
|
}
|
|
1326
|
-
function wtGo(sid, step, { group = null } = {}) {
|
|
1373
|
+
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
1327
1374
|
const node = wtUi.node; if (!node) return
|
|
1328
1375
|
if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
|
|
1329
1376
|
if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
|
|
1330
1377
|
const k = wtKey(sid)
|
|
1331
1378
|
const deck = node.querySelector('#wtDeck')
|
|
1332
|
-
|
|
1379
|
+
// 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
|
|
1380
|
+
const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
|
|
1381
|
+
if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
|
|
1333
1382
|
wtUi.cur.set(k, step)
|
|
1334
1383
|
for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1335
1384
|
for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1336
1385
|
const el = node.querySelector('#wt-' + CSS.escape(step))
|
|
1337
|
-
|
|
1386
|
+
const row = fileRow()
|
|
1387
|
+
if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
|
|
1338
1388
|
const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
|
|
1339
1389
|
}
|
|
1340
1390
|
// 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
|
|
@@ -1396,8 +1446,11 @@ function wtAsk(sid, text) {
|
|
|
1396
1446
|
const data = wtUi.data.get(wtKey(sid))
|
|
1397
1447
|
// 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
|
|
1398
1448
|
const picks = wtPicksNow(data)
|
|
1449
|
+
// 2026-11-03 which changes the question is about, as the toggle has it right now
|
|
1450
|
+
const line = scopeAskLine(data)
|
|
1451
|
+
const lead = (m) => (line ? line + '\n' + m : m)
|
|
1399
1452
|
if (picks.length) {
|
|
1400
|
-
const msg = refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text)
|
|
1453
|
+
const msg = lead(refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text))
|
|
1401
1454
|
wtUi.picks = []
|
|
1402
1455
|
return wtAskPut(sid, msg)
|
|
1403
1456
|
}
|
|
@@ -1405,7 +1458,7 @@ function wtAsk(sid, text) {
|
|
|
1405
1458
|
const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
|
|
1406
1459
|
const co = st && st.callouts[0]
|
|
1407
1460
|
const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
|
|
1408
|
-
const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
|
|
1461
|
+
const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
|
|
1409
1462
|
wtAskPut(sid, msg)
|
|
1410
1463
|
}
|
|
1411
1464
|
function wtAskPut(sid, msg) {
|
|
@@ -1772,6 +1825,9 @@ function repoRow(shown, f, tail = '') {
|
|
|
1772
1825
|
return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
|
|
1773
1826
|
}
|
|
1774
1827
|
|
|
1828
|
+
// 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
|
|
1829
|
+
// (the counts are per log; a chip's log is its sessions' decisionRepo)
|
|
1830
|
+
const repoReviewHtml = (c) => { const rv = projectsUi.repoReviews || {}; if (!Object.keys(rv).length) return ''; const s = (state.sessions || []).find((x) => x.decisionRepo && rv[x.decisionRepo] && repoKeyOf(x) === c.key); return s ? reviewCountHtml(rv[s.decisionRepo], { esc, cls: 'pchip-rc' }) : '' }
|
|
1775
1831
|
// The repo chip row, shared by the board and the timeline: All, the top 8,
|
|
1776
1832
|
// and More ▾ with a filter box. chips: repoChips() output.
|
|
1777
1833
|
function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
@@ -1782,7 +1838,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1782
1838
|
// with nothing in this view: a filter is never active out of sight.
|
|
1783
1839
|
chips = orderSelectedFirst(chips, selected)
|
|
1784
1840
|
const { shown: row, more } = splitChips(chips, selected, 8)
|
|
1785
|
-
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1841
|
+
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + repoReviewHtml(c) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1786
1842
|
const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
|
|
1787
1843
|
let menu = ''
|
|
1788
1844
|
if (more.length) {
|
|
@@ -1796,13 +1852,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1796
1852
|
// ------------------------------------------------------------ projects (0.9.0)
|
|
1797
1853
|
// A project is a named group of repos (projects.json in the synced folder).
|
|
1798
1854
|
// Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
|
|
1799
|
-
const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
|
|
1855
|
+
const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
|
|
1800
1856
|
async function loadProjects({ fresh = false } = {}) {
|
|
1801
1857
|
if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
|
|
1802
|
-
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1858
|
+
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.repoReviews = d.repoReviews || {}; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1803
1859
|
return projectsUi
|
|
1804
1860
|
}
|
|
1805
|
-
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
|
|
1861
|
+
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; if (d.repoReviews) projectsUi.repoReviews = d.repoReviews; projectsUi.at = Date.now() } }
|
|
1806
1862
|
// The small chip next to the repo: tap = show only this project.
|
|
1807
1863
|
function projChip(s) {
|
|
1808
1864
|
const p = s && s.repoProject
|
|
@@ -1865,7 +1921,7 @@ function projectRow(list, sel, action, counts, tail = '') {
|
|
|
1865
1921
|
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>' : ''
|
|
1866
1922
|
// the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
|
|
1867
1923
|
return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
|
|
1868
|
-
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>'
|
|
1924
|
+
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>'
|
|
1869
1925
|
}
|
|
1870
1926
|
// 0.9.12: the board's own way in to a project with several repos: a "New
|
|
1871
1927
|
// project" button at the end of the project row (or the repo row before there
|
|
@@ -2542,9 +2598,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
|
|
|
2542
2598
|
const SESSION_CACHE_MAX = 8
|
|
2543
2599
|
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
2544
2600
|
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
2601
|
+
// 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
|
|
2602
|
+
// (the longest whole-word cut that fits, with …), never mid-word by the
|
|
2603
|
+
// ellipsis; the whole name is in its tip.
|
|
2604
|
+
function fitTitleWords(el, text) {
|
|
2605
|
+
el.textContent = text
|
|
2606
|
+
if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
|
|
2607
|
+
if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
|
|
2608
|
+
let lo = 4, hi = text.length, best = ''
|
|
2609
|
+
while (lo <= hi) {
|
|
2610
|
+
const mid = (lo + hi) >> 1
|
|
2611
|
+
el.textContent = cutTitle(text, mid)
|
|
2612
|
+
if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
|
|
2613
|
+
}
|
|
2614
|
+
el.textContent = best || cutTitle(text, lo)
|
|
2615
|
+
}
|
|
2616
|
+
addEventListener('resize', () => { const t = document.querySelector('#live.full .lf-title'); const full = t && t.getAttribute('data-tip'); if (full) fitTitleWords(t, full) }, { passive: true }) // a turned phone re-fits
|
|
2617
|
+
// 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
|
|
2618
|
+
const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
|
|
2619
|
+
function whereLineHtml(s) {
|
|
2620
|
+
const w = sessionWhere(s)
|
|
2621
|
+
return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
|
|
2622
|
+
}
|
|
2545
2623
|
function sessionShellHtml(s) {
|
|
2546
2624
|
const head = s
|
|
2547
|
-
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1
|
|
2625
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
|
|
2548
2626
|
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
2549
2627
|
return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
|
|
2550
2628
|
'<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
|
|
@@ -2750,7 +2828,7 @@ async function loadWid(id) {
|
|
|
2750
2828
|
paintWid(id)
|
|
2751
2829
|
await Promise.all([
|
|
2752
2830
|
api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
|
|
2753
|
-
api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
2831
|
+
api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
2754
2832
|
])
|
|
2755
2833
|
ui.loading = false
|
|
2756
2834
|
// a file you had open shows its lines again
|
|
@@ -2762,7 +2840,7 @@ async function loadWidFile(id, p) {
|
|
|
2762
2840
|
const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
|
|
2763
2841
|
if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
|
|
2764
2842
|
ui.fileLoading[p] = true; paintWid(id)
|
|
2765
|
-
try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
|
|
2843
|
+
try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p) + filesQ('&')); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
|
|
2766
2844
|
delete ui.fileLoading[p]
|
|
2767
2845
|
paintWid(id)
|
|
2768
2846
|
}
|
|
@@ -2850,8 +2928,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2850
2928
|
// title is being made, its spinner sits in the cluster.
|
|
2851
2929
|
const fullTitle = sessionTitle(s)
|
|
2852
2930
|
const title = canRename
|
|
2853
|
-
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1
|
|
2854
|
-
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1
|
|
2931
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
2932
|
+
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
2855
2933
|
const menu = fsMenuItems(s, mine)
|
|
2856
2934
|
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2857
2935
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
@@ -2861,7 +2939,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2861
2939
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2862
2940
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2863
2941
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2864
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2942
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2865
2943
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2866
2944
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2867
2945
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -2895,7 +2973,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2895
2973
|
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2896
2974
|
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2897
2975
|
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>'
|
|
2898
|
-
|
|
2976
|
+
side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
|
|
2977
|
+
decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
|
|
2978
|
+
if (s.decisionRepo) noteSessionDecisions(s)
|
|
2979
|
+
side += '<div id="fsDecided">' + panelDecidedFold(s) + '</div>' // 2026-11-05 every decision the session made (its repo's log, else all its checkpoints'); 1k Accept / Reject / Edit right here
|
|
2980
|
+
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, { log: sessionLogBy(s) }) + '</div>' }, { esc })
|
|
2899
2981
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2900
2982
|
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>'
|
|
2901
2983
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
@@ -3379,21 +3461,83 @@ function setFullClass(on) {
|
|
|
3379
3461
|
applyNav()
|
|
3380
3462
|
}
|
|
3381
3463
|
// The window as you see it: on a phone the keyboard takes the bottom of it,
|
|
3382
|
-
// and the composer stays just above the keyboard.
|
|
3464
|
+
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3465
|
+
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3466
|
+
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3467
|
+
const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
|
|
3383
3468
|
function fitFullViewport() {
|
|
3384
3469
|
const vv = window.visualViewport
|
|
3385
|
-
const
|
|
3386
|
-
if (!
|
|
3470
|
+
const html = document.documentElement, st = html.style
|
|
3471
|
+
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 }
|
|
3387
3472
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3388
3473
|
const strip = document.getElementById('tabStrip')
|
|
3389
3474
|
const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
|
|
3390
3475
|
st.setProperty('--lf-bar', bar + 'px')
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
st.
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3476
|
+
const typing = isTypingTarget(document.activeElement)
|
|
3477
|
+
const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
|
|
3478
|
+
if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
|
|
3479
|
+
st.setProperty('--lf-h', fit.h + 'px')
|
|
3480
|
+
st.setProperty('--lf-top', fit.top + 'px')
|
|
3481
|
+
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3482
|
+
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3483
|
+
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3484
|
+
if (typing) keepTypingInView()
|
|
3485
|
+
}
|
|
3486
|
+
// 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
|
|
3487
|
+
// bring it back): while you type, check where the composer really is against what you can see, from
|
|
3488
|
+
// this moment's numbers only, and if it isn't in view move full screen by the difference.
|
|
3489
|
+
// (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
|
|
3490
|
+
function keepTypingInView() {
|
|
3491
|
+
const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
|
|
3492
|
+
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3493
|
+
const box = el.closest('.live-box.full')
|
|
3494
|
+
if (!box) return
|
|
3495
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3496
|
+
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3497
|
+
}
|
|
3498
|
+
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
3499
|
+
// page under it) fits full screen then and looks again a few beats after: a swap's last change can
|
|
3500
|
+
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3501
|
+
function onViewportChange(ev) {
|
|
3502
|
+
fitFullViewport()
|
|
3503
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
|
|
3504
|
+
}
|
|
3505
|
+
function settleWhileTyping(from = 0) {
|
|
3506
|
+
clearTimeout(lfKb.settle)
|
|
3507
|
+
if (from >= SETTLE_WHILE_TYPING_MS.length) return
|
|
3508
|
+
let i = from
|
|
3509
|
+
const step = () => {
|
|
3510
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3511
|
+
fitFullViewport()
|
|
3512
|
+
if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
|
|
3513
|
+
}
|
|
3514
|
+
lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
|
|
3515
|
+
}
|
|
3516
|
+
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
|
|
3517
|
+
window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
|
|
3518
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
|
|
3519
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3520
|
+
document.addEventListener('focusin', (e) => {
|
|
3521
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3522
|
+
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3523
|
+
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3524
|
+
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3525
|
+
settleWhileTyping(0)
|
|
3526
|
+
})
|
|
3527
|
+
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3528
|
+
// goes down, whatever iOS reports or doesn't on the way.
|
|
3529
|
+
document.addEventListener('focusout', (e) => {
|
|
3530
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3531
|
+
clearTimeout(lfKb.settle)
|
|
3532
|
+
let i = 0
|
|
3533
|
+
const step = () => {
|
|
3534
|
+
fitFullViewport()
|
|
3535
|
+
if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
|
|
3536
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3537
|
+
else lfKb.scroll = null
|
|
3538
|
+
}
|
|
3539
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3540
|
+
})
|
|
3397
3541
|
function fullAfterRender(sid) {
|
|
3398
3542
|
const box = document.querySelector('.live-box.full')
|
|
3399
3543
|
setFullClass(!!box)
|
|
@@ -3401,7 +3545,9 @@ function fullAfterRender(sid) {
|
|
|
3401
3545
|
if (box) {
|
|
3402
3546
|
const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
|
|
3403
3547
|
const t = box.querySelector('.lf-title')
|
|
3404
|
-
if (t) t
|
|
3548
|
+
if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
|
|
3549
|
+
const w = box.querySelector('.lf-where')
|
|
3550
|
+
if (w) { w.textContent = sessionWhere(s); if (w.textContent) w.setAttribute('data-tip', wherePathOf(s) || w.textContent); else w.removeAttribute('data-tip') } // 2026-11-05: folder@branch, muted, its own line
|
|
3405
3551
|
paintLfTabs()
|
|
3406
3552
|
const ol = box.querySelector('.live-log')
|
|
3407
3553
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
@@ -3638,11 +3784,40 @@ function composerFor(el) {
|
|
|
3638
3784
|
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
3639
3785
|
return box && !box.classList.contains('off') ? box : null
|
|
3640
3786
|
}
|
|
3787
|
+
// The picker the paperclip opens lives outside the page (0.9.18). A phone's
|
|
3788
|
+
// photo picker covers the board for seconds; coming back is a wake-up (focus
|
|
3789
|
+
// after more than AWAY_MS away) that redraws the page, and iOS hands the
|
|
3790
|
+
// picked photo over only after that, to the <input> it opened from. A picker
|
|
3791
|
+
// inside the composer was gone by then (the redraw made a new one), so its
|
|
3792
|
+
// change event went nowhere and the photo never showed. This one is never
|
|
3793
|
+
// redrawn; the composer's own hidden input stays for drops and tests.
|
|
3794
|
+
let attPicker = null
|
|
3795
|
+
function attachPicker() {
|
|
3796
|
+
if (attPicker && attPicker.isConnected) return attPicker
|
|
3797
|
+
attPicker = document.createElement('input')
|
|
3798
|
+
attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
|
|
3799
|
+
attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
|
|
3800
|
+
attPicker.setAttribute('aria-hidden', 'true')
|
|
3801
|
+
document.body.appendChild(attPicker)
|
|
3802
|
+
return attPicker
|
|
3803
|
+
}
|
|
3804
|
+
function openPicker(sid) {
|
|
3805
|
+
if (!sid) return
|
|
3806
|
+
const pk = attachPicker()
|
|
3807
|
+
pk.dataset.sid = sid
|
|
3808
|
+
pk.value = ''
|
|
3809
|
+
pk.click() // still inside the tap: iOS opens a picker only from a user gesture
|
|
3810
|
+
}
|
|
3811
|
+
function takePicked(inp) {
|
|
3812
|
+
const files = Array.from(inp.files || [])
|
|
3813
|
+
if (!files.length) return
|
|
3814
|
+
inp.value = '' // the same file can be picked again
|
|
3815
|
+
try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
|
|
3816
|
+
}
|
|
3641
3817
|
document.addEventListener('change', (ev) => {
|
|
3642
3818
|
const inp = ev.target
|
|
3643
3819
|
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
3644
|
-
|
|
3645
|
-
inp.value = '' // the same file can be picked again
|
|
3820
|
+
takePicked(inp)
|
|
3646
3821
|
})
|
|
3647
3822
|
document.addEventListener('paste', (ev) => {
|
|
3648
3823
|
const t = ev.target
|
|
@@ -4385,10 +4560,11 @@ async function aroundWire(id) {
|
|
|
4385
4560
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4386
4561
|
}
|
|
4387
4562
|
}
|
|
4388
|
-
|
|
4563
|
+
// decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
|
|
4564
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
|
|
4389
4565
|
return list.map((cp) =>
|
|
4390
4566
|
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
|
|
4391
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
|
|
4567
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
|
|
4392
4568
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4393
4569
|
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4394
4570
|
(wtSid && cp === newestCp ? '<button type="button" class="linkish wt-since" data-action="wt-open" data-id="' + esc(wtSid) + '" data-scope="checkpoint">' + icon('route') + 'Walk through since here</button>' : '') + // 0.9.17
|
|
@@ -4398,6 +4574,268 @@ function cpHeadLine(cp) {
|
|
|
4398
4574
|
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>' +
|
|
4399
4575
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4400
4576
|
}
|
|
4577
|
+
// 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
|
|
4578
|
+
const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4579
|
+
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4580
|
+
// 2026-11-05 the session's rows in its repo's log (null until loaded, or with no repo), and the same keyed by text
|
|
4581
|
+
const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
|
|
4582
|
+
const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
|
|
4583
|
+
// The Decided fold: every decision the session made, shown whenever there's at least one
|
|
4584
|
+
function panelDecidedFold(s) {
|
|
4585
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4586
|
+
if (!list.length) return ''
|
|
4587
|
+
return foldHtml({ key: 'cp-decided', title: 'Decided', count: sessionDecisionsCount(list), open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc })
|
|
4588
|
+
}
|
|
4589
|
+
// The session panel reads its repo's log (once per repo per visit, quietly); the old rows stay on screen until the new ones land
|
|
4590
|
+
function noteSessionDecisions(s) {
|
|
4591
|
+
const repo = s && s.decisionRepo
|
|
4592
|
+
if (!repo || decisionsUi.loadedRepo[repo]) return
|
|
4593
|
+
decisionsUi.loadedRepo[repo] = true
|
|
4594
|
+
api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
|
|
4595
|
+
decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
|
|
4596
|
+
const ps = panelSess()
|
|
4597
|
+
if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
|
|
4598
|
+
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4599
|
+
}
|
|
4600
|
+
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
4601
|
+
const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
|
|
4602
|
+
const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4603
|
+
async function viewTestplan(arg, params) {
|
|
4604
|
+
clearTimeout(tpUi.timer)
|
|
4605
|
+
if (arg === 'new') {
|
|
4606
|
+
const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
|
|
4607
|
+
const key = JSON.stringify(q)
|
|
4608
|
+
if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
|
|
4609
|
+
paintTestplanNew()
|
|
4610
|
+
const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
|
|
4611
|
+
try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
|
|
4612
|
+
try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
|
|
4613
|
+
paintTestplanNew()
|
|
4614
|
+
const w = tpUi.nu.preview && tpUi.nu.preview.walking
|
|
4615
|
+
if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
|
|
4616
|
+
return
|
|
4617
|
+
}
|
|
4618
|
+
const machine = params.get('machine') || null
|
|
4619
|
+
if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
|
|
4620
|
+
tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
|
|
4621
|
+
paintTestplan()
|
|
4622
|
+
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)
|
|
4623
|
+
}
|
|
4624
|
+
function paintTestplanNew() {
|
|
4625
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
|
|
4626
|
+
const n = tpUi.nu
|
|
4627
|
+
$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>' : '')
|
|
4628
|
+
}
|
|
4629
|
+
function paintTestplan() {
|
|
4630
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
|
|
4631
|
+
$app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
|
|
4632
|
+
}
|
|
4633
|
+
async function tpCheck(id, check, body) {
|
|
4634
|
+
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)) }
|
|
4635
|
+
}
|
|
4636
|
+
document.addEventListener('click', async (ev) => {
|
|
4637
|
+
const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
|
|
4638
|
+
if (!b || b.tagName === 'FORM' || b.disabled) return
|
|
4639
|
+
const a = b.dataset.action
|
|
4640
|
+
if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
|
|
4641
|
+
if (a === 'tp-run' || a === 'tp-walk') {
|
|
4642
|
+
const n = tpUi.nu
|
|
4643
|
+
if (!n) return
|
|
4644
|
+
const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
|
|
4645
|
+
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 }
|
|
4646
|
+
n.starting = true; paintTestplanNew()
|
|
4647
|
+
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() }
|
|
4648
|
+
return
|
|
4649
|
+
}
|
|
4650
|
+
const id = b.dataset.id, check = b.dataset.check
|
|
4651
|
+
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 }) }
|
|
4652
|
+
if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
|
|
4653
|
+
if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
|
|
4654
|
+
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 }
|
|
4655
|
+
if (a === 'tp-download') {
|
|
4656
|
+
if (!tpUi.plan) return
|
|
4657
|
+
const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
|
|
4658
|
+
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()
|
|
4659
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
4660
|
+
return
|
|
4661
|
+
}
|
|
4662
|
+
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)) } }
|
|
4663
|
+
})
|
|
4664
|
+
document.addEventListener('submit', async (ev) => {
|
|
4665
|
+
const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
|
|
4666
|
+
if (!f) return
|
|
4667
|
+
ev.preventDefault()
|
|
4668
|
+
if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
|
|
4669
|
+
if (f.dataset.action === 'tp-propose-save') {
|
|
4670
|
+
try {
|
|
4671
|
+
const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
|
|
4672
|
+
tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
|
|
4673
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4674
|
+
}
|
|
4675
|
+
})
|
|
4676
|
+
// 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
|
|
4677
|
+
// count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
|
|
4678
|
+
function paintPanelDecided(s) {
|
|
4679
|
+
const slot = document.getElementById('fsDecided')
|
|
4680
|
+
if (!slot || !s) return
|
|
4681
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4682
|
+
const fold = slot.querySelector('[data-fold="cp-decided"]')
|
|
4683
|
+
if (!list.length) { if (fold) slot.innerHTML = ''; return }
|
|
4684
|
+
if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
|
|
4685
|
+
const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
|
|
4686
|
+
if (n && n.textContent !== cnt) n.textContent = cnt
|
|
4687
|
+
const box = fold.querySelector('.fs-fold-b')
|
|
4688
|
+
const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4689
|
+
if (box && box.innerHTML !== html) box.innerHTML = html
|
|
4690
|
+
// the Timeline's older checkpoints read the same log
|
|
4691
|
+
const by = sessionLogBy(s)
|
|
4692
|
+
if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
|
|
4693
|
+
const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
|
|
4694
|
+
if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4698
|
+
function refreshPanelDecisions(s) {
|
|
4699
|
+
if (!s || !s.decisionRepo) return
|
|
4700
|
+
decisionsUi.loadedRepo[s.decisionRepo] = false
|
|
4701
|
+
noteSessionDecisions(s)
|
|
4702
|
+
}
|
|
4703
|
+
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4704
|
+
async function viewDecisions(arg) {
|
|
4705
|
+
const raw = decodeURIComponent(arg || '')
|
|
4706
|
+
if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
|
|
4707
|
+
decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
|
|
4708
|
+
const want = state.route && state.route.params && state.route.params.get('d')
|
|
4709
|
+
if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
|
|
4710
|
+
paintDecisions()
|
|
4711
|
+
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) } }
|
|
4712
|
+
}
|
|
4713
|
+
function paintDecisions() {
|
|
4714
|
+
if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
|
|
4715
|
+
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
|
|
4716
|
+
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4717
|
+
}
|
|
4718
|
+
async function decisionAction(repo, ref, action, body = {}) {
|
|
4719
|
+
try {
|
|
4720
|
+
const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4721
|
+
decisionsUi.loadedRepo = {}
|
|
4722
|
+
// a walkthrough citing it shows its new word (Proposed goes once accepted)
|
|
4723
|
+
const dd = r && r.decision
|
|
4724
|
+
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 = '' }
|
|
4725
|
+
if (wtUi.sid) wtFill(wtSession(wtUi.sid))
|
|
4726
|
+
const dv = document.querySelector('.dv-isdec:not([hidden])')
|
|
4727
|
+
if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
|
|
4728
|
+
if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
|
|
4729
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4730
|
+
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4731
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4732
|
+
}
|
|
4733
|
+
document.addEventListener('click', async (ev) => {
|
|
4734
|
+
const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
|
|
4735
|
+
if (!btn || btn.tagName === 'FORM') return
|
|
4736
|
+
const a = btn.dataset.action
|
|
4737
|
+
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4738
|
+
if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
|
|
4739
|
+
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() }
|
|
4740
|
+
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4741
|
+
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4742
|
+
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 }
|
|
4743
|
+
if (a === 'dec-show') {
|
|
4744
|
+
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
4745
|
+
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 }
|
|
4746
|
+
if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
|
|
4747
|
+
return
|
|
4748
|
+
}
|
|
4749
|
+
if (a === 'dec-store') {
|
|
4750
|
+
const on = btn.dataset.on === 'true'
|
|
4751
|
+
try {
|
|
4752
|
+
await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
|
|
4753
|
+
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')
|
|
4754
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4755
|
+
else loadProjectDecisions(qaUi.token, true)
|
|
4756
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4757
|
+
}
|
|
4758
|
+
})
|
|
4759
|
+
// A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
|
|
4760
|
+
document.addEventListener('click', (ev) => {
|
|
4761
|
+
const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
|
|
4762
|
+
if (!b) return
|
|
4763
|
+
ev.preventDefault()
|
|
4764
|
+
const s = wtSession(wtUi.sid)
|
|
4765
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
|
|
4766
|
+
})
|
|
4767
|
+
// 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
|
|
4768
|
+
const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4769
|
+
let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
|
|
4770
|
+
async function openDecisionViewer(repo, id, from, ctx = {}) {
|
|
4771
|
+
if (!repo || !id) return
|
|
4772
|
+
decViewerCtx = { repo, id, from, ctx }
|
|
4773
|
+
let v
|
|
4774
|
+
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4775
|
+
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
4776
|
+
const d = byId.get(id)
|
|
4777
|
+
const el = docViewerEl()
|
|
4778
|
+
el.querySelector('.dv-title').textContent = 'Decision ' + id
|
|
4779
|
+
el.querySelector('.dv-dl').hidden = true
|
|
4780
|
+
el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
|
|
4781
|
+
el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
|
|
4782
|
+
// the chain: what it replaced (oldest first), the decision, what replaced it
|
|
4783
|
+
const chain = []
|
|
4784
|
+
if (d) {
|
|
4785
|
+
const back = []; let cur = d, guard = 0
|
|
4786
|
+
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 }
|
|
4787
|
+
chain.push(...back, d)
|
|
4788
|
+
cur = d; guard = 0
|
|
4789
|
+
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4790
|
+
}
|
|
4791
|
+
// 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
|
|
4792
|
+
// 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
|
|
4793
|
+
const trusted = decisionsTrusted()
|
|
4794
|
+
const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
|
|
4795
|
+
let offer = ''
|
|
4796
|
+
if (d && ctx.departs && ctx.sessionId) {
|
|
4797
|
+
const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
|
|
4798
|
+
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">' +
|
|
4799
|
+
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>'
|
|
4800
|
+
}
|
|
4801
|
+
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 +
|
|
4802
|
+
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4803
|
+
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4804
|
+
refUi.from = from || null
|
|
4805
|
+
el.hidden = false
|
|
4806
|
+
document.documentElement.classList.add('dv-open')
|
|
4807
|
+
const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
|
|
4808
|
+
}
|
|
4809
|
+
document.addEventListener('submit', (ev) => {
|
|
4810
|
+
const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
|
|
4811
|
+
if (!f) return
|
|
4812
|
+
ev.preventDefault()
|
|
4813
|
+
const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
|
|
4814
|
+
decisionsUi.editing = null
|
|
4815
|
+
decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
|
|
4816
|
+
})
|
|
4817
|
+
// The project page's Decisions section
|
|
4818
|
+
// (2026-11-05) or a single repo's page: that repo's log (the page names its id)
|
|
4819
|
+
const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
|
|
4820
|
+
async function loadProjectDecisions(token, force = false) {
|
|
4821
|
+
if (!token) return
|
|
4822
|
+
const scopeArg = pageDecisionScope(token)
|
|
4823
|
+
if (!scopeArg) return
|
|
4824
|
+
try {
|
|
4825
|
+
const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
|
|
4826
|
+
decisionsUi.project = { token, v, scopeArg }
|
|
4827
|
+
paintProjectDecisions()
|
|
4828
|
+
} catch { decisionsUi.project = null }
|
|
4829
|
+
}
|
|
4830
|
+
function paintProjectDecisions() {
|
|
4831
|
+
const box = document.getElementById('projDecisionsBox')
|
|
4832
|
+
const pd = decisionsUi.project
|
|
4833
|
+
if (!box || !pd || pd.token !== qaUi.token) return
|
|
4834
|
+
const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
|
|
4835
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
|
|
4836
|
+
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4837
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
|
|
4838
|
+
}
|
|
4401
4839
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4402
4840
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
4403
4841
|
function cpLatestHtml(cp, wtSid, paths) {
|
|
@@ -4425,10 +4863,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
|
4425
4863
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
4426
4864
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
4427
4865
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
4428
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
|
|
4866
|
+
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
|
|
4429
4867
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
4430
4868
|
// tap repaints from what we have and reads again only when that is old.
|
|
4431
|
-
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 } }
|
|
4869
|
+
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 } }
|
|
4432
4870
|
function paintPrs(d) {
|
|
4433
4871
|
const q = document.getElementById('prs-q')
|
|
4434
4872
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -4577,7 +5015,8 @@ function briefAction(id, sessions, viewer) {
|
|
|
4577
5015
|
|
|
4578
5016
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4579
5017
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
4580
|
-
|
|
5018
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
5019
|
+
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)
|
|
4581
5020
|
const out = []
|
|
4582
5021
|
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4583
5022
|
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
@@ -6601,6 +7040,8 @@ async function render() {
|
|
|
6601
7040
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
6602
7041
|
else if (r.name === 'new') await viewNewSession()
|
|
6603
7042
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
7043
|
+
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
7044
|
+
else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
|
|
6604
7045
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6605
7046
|
else if (r.name === 'setup') await viewSetup()
|
|
6606
7047
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -6635,6 +7076,7 @@ async function viewProject(token) {
|
|
|
6635
7076
|
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
|
|
6636
7077
|
qaUi.page = page
|
|
6637
7078
|
paintProject()
|
|
7079
|
+
if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
|
|
6638
7080
|
if (fresh) window.scrollTo(0, 0)
|
|
6639
7081
|
const r = state.route || {}
|
|
6640
7082
|
if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
|
|
@@ -6654,6 +7096,11 @@ function paintProject() {
|
|
|
6654
7096
|
const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
6655
7097
|
$app.innerHTML = out.html
|
|
6656
7098
|
fillAsk($app, out.keys)
|
|
7099
|
+
if (p.scope && (p.scope.kind === 'project' || p.decisionRepo)) { // 2026-11-03 decision memory: the project's log and where it's kept; 2026-11-05 a repo's log
|
|
7100
|
+
const at = document.getElementById('projAsks')
|
|
7101
|
+
if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
|
|
7102
|
+
paintProjectDecisions()
|
|
7103
|
+
}
|
|
6657
7104
|
const ta = document.getElementById('askIn')
|
|
6658
7105
|
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 {} } }
|
|
6659
7106
|
}
|
|
@@ -8493,6 +8940,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8493
8940
|
if (action === 'wt-close') return wtClose()
|
|
8494
8941
|
if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
|
|
8495
8942
|
if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
|
|
8943
|
+
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
8496
8944
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8497
8945
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
|
8498
8946
|
if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
|
|
@@ -8523,7 +8971,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8523
8971
|
const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
|
|
8524
8972
|
return
|
|
8525
8973
|
}
|
|
8526
|
-
if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
|
|
8974
|
+
if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
|
|
8527
8975
|
if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
|
|
8528
8976
|
// 2026-10-30 walkthrough round 3
|
|
8529
8977
|
if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
|
|
@@ -8583,6 +9031,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8583
9031
|
// 0.9.8 What it did
|
|
8584
9032
|
if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
|
|
8585
9033
|
if (action === 'wid-refresh') return loadWid(id)
|
|
9034
|
+
if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
|
|
8586
9035
|
if (action === 'wid-tool') {
|
|
8587
9036
|
const ui = widOf(id), t = btn.dataset.tool
|
|
8588
9037
|
ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
|
|
@@ -8623,6 +9072,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
8623
9072
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
8624
9073
|
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
8625
9074
|
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
9075
|
+
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
|
|
9076
|
+
if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
|
|
8626
9077
|
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
|
|
8627
9078
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
8628
9079
|
if (action === 'pr-unlink') {
|
|
@@ -9151,7 +9602,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9151
9602
|
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
9152
9603
|
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
9153
9604
|
if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
|
|
9154
|
-
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.
|
|
9605
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
|
|
9155
9606
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
9156
9607
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
9157
9608
|
if (action === 'att-open') return openAttachment(btn)
|
|
@@ -9491,7 +9942,7 @@ function docViewerEl() {
|
|
|
9491
9942
|
document.body.appendChild(el)
|
|
9492
9943
|
return el
|
|
9493
9944
|
}
|
|
9494
|
-
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') }
|
|
9945
|
+
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') }
|
|
9495
9946
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
9496
9947
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
9497
9948
|
if (!sessionId || (!path && !plan)) return
|