@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11
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 +46 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +787 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/SECURITY.md +1 -1
- 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 +211 -13
- package/mod/hooks/register.js +196 -7
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +279 -4
- package/ui/app.js +661 -53
- package/ui/ask.js +5 -3
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +245 -0
- package/ui/fs-side.js +29 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +36 -5
- package/ui/prs.js +15 -5
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/summary-examples.js +2 -2
- package/ui/tabs.js +10 -3
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +73 -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, sheetAside, SHEET_BACK_AFTER_BLUR_MS, 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,26 +40,29 @@ 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
|
|
52
55
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
53
56
|
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
|
|
54
|
-
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
57
|
+
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } 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
|
|
61
64
|
import { lastReply, ghostShows } from './ghost.js'
|
|
65
|
+
import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
|
|
62
66
|
import { digestHeadline, todayHidden, hideToday, todayCardHtml } from './today.js' // 0.9.16 the board's Today card // 0.9.16 the suggested next message // 0.9.16 the session panel beside full screen
|
|
63
67
|
import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
|
|
64
68
|
import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
|
|
@@ -73,6 +77,9 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
|
|
|
73
77
|
import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
|
|
74
78
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
75
79
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
80
|
+
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
81
|
+
import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
82
|
+
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
76
83
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
77
84
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
78
85
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -468,15 +475,38 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
|
|
|
468
475
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
469
476
|
// 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
|
|
470
477
|
const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
|
|
478
|
+
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
479
|
+
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
480
|
+
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.'
|
|
481
|
+
// 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
|
|
482
|
+
// sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
|
|
483
|
+
const hooksQuietHtml = (s) => {
|
|
484
|
+
if (!s || s.endedAt) return ''
|
|
485
|
+
const k = s.hooksSkipped
|
|
486
|
+
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
|
|
487
|
+
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>' : ''
|
|
488
|
+
}
|
|
489
|
+
// the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
|
|
490
|
+
document.addEventListener('click', (e) => {
|
|
491
|
+
const b = e.target.closest && e.target.closest('[data-hookfix]')
|
|
492
|
+
if (!b) return
|
|
493
|
+
e.preventDefault()
|
|
494
|
+
const sid = b.dataset.hookfix
|
|
495
|
+
const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
|
|
496
|
+
openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
|
|
497
|
+
})
|
|
471
498
|
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
499
|
|
|
473
500
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
474
501
|
|
|
475
502
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
476
|
-
const EXTRA_TITLES = [['decisions', '
|
|
503
|
+
const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
477
504
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
478
|
-
|
|
479
|
-
|
|
505
|
+
// decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
|
|
506
|
+
const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
|
|
507
|
+
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>'
|
|
508
|
+
function cpExtras(cp, paths = null, decided = null) {
|
|
509
|
+
let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
|
|
480
510
|
for (const [k, title] of EXTRA_TITLES) {
|
|
481
511
|
const v = cp[k]
|
|
482
512
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
@@ -495,11 +525,11 @@ function cpExtras(cp, paths = null) {
|
|
|
495
525
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
496
526
|
// part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
|
|
497
527
|
// 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 } = {}) {
|
|
528
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
|
|
499
529
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
500
530
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
501
531
|
const you = /^YOU:/i.test(cp.next || '')
|
|
502
|
-
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
532
|
+
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
|
|
503
533
|
const dd = (t, cls = '') => {
|
|
504
534
|
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
505
535
|
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
@@ -518,7 +548,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
518
548
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
519
549
|
(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
550
|
'</dl>' +
|
|
521
|
-
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp
|
|
551
|
+
(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
552
|
}
|
|
523
553
|
|
|
524
554
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -1104,7 +1134,11 @@ function card(s) {
|
|
|
1104
1134
|
// own node is kept between the page's redraws (the live poll repaints the page
|
|
1105
1135
|
// every few seconds) so the scroll, the drawn code and the open groups stay.
|
|
1106
1136
|
// Mark reviewed is the session's own record (session.md, like a pin).
|
|
1107
|
-
|
|
1137
|
+
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1138
|
+
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1139
|
+
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1140
|
+
function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
|
|
1141
|
+
function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
|
|
1108
1142
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1109
1143
|
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
1144
|
function wtRestore(sid) {
|
|
@@ -1135,14 +1169,27 @@ async function wtCheck(sid) {
|
|
|
1135
1169
|
if (wtUi.checking.has(k)) return
|
|
1136
1170
|
wtUi.checking.add(k)
|
|
1137
1171
|
try {
|
|
1138
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1172
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
|
|
1139
1173
|
const had = wtUi.data.get(k)
|
|
1140
1174
|
if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
|
|
1175
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1141
1176
|
wtUi.data.set(k, r)
|
|
1142
1177
|
if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
|
|
1143
1178
|
}
|
|
1144
1179
|
} catch {} finally { wtUi.checking.delete(k) }
|
|
1145
1180
|
}
|
|
1181
|
+
// 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
|
|
1182
|
+
// it again, never a model run on its own: peek), its Files view and What it did show the other side at once
|
|
1183
|
+
function setFileScope(v, { id = null } = {}) {
|
|
1184
|
+
const want = v === 'folder' ? 'folder' : 'session'
|
|
1185
|
+
if (fileScope.get() === want) return
|
|
1186
|
+
fileScope.set(want)
|
|
1187
|
+
wtUi.filter = 'all'; wtUi.picks = []
|
|
1188
|
+
for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
|
|
1189
|
+
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: {} }) }
|
|
1190
|
+
if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
|
|
1191
|
+
else if (id) repaintSession(id)
|
|
1192
|
+
}
|
|
1146
1193
|
function wtClose() {
|
|
1147
1194
|
const sid = wtUi.sid
|
|
1148
1195
|
document.documentElement.classList.remove('wt-open')
|
|
@@ -1165,7 +1212,7 @@ async function wtSizeFetch(sid) {
|
|
|
1165
1212
|
e.pending = true; wtSize.set(sid, e)
|
|
1166
1213
|
const sc = wtUi.scope.get(sid)
|
|
1167
1214
|
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
|
|
1215
|
+
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
1216
|
wtSize.set(sid, { at: Date.now(), data, pending: false })
|
|
1170
1217
|
for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
|
|
1171
1218
|
slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
|
|
@@ -1190,7 +1237,7 @@ let loPop = null
|
|
|
1190
1237
|
function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
|
|
1191
1238
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1192
1239
|
// 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 } = {}) {
|
|
1240
|
+
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1194
1241
|
const k = wtKey(sid)
|
|
1195
1242
|
const had = wtUi.data.get(k)
|
|
1196
1243
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
@@ -1198,7 +1245,8 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
|
|
|
1198
1245
|
wtUi.loading.add(k); wtUi.error.delete(k)
|
|
1199
1246
|
wtFill(wtSession(sid))
|
|
1200
1247
|
try {
|
|
1201
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1248
|
+
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
|
|
1249
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1202
1250
|
wtUi.data.set(k, r)
|
|
1203
1251
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1204
1252
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1323,18 +1371,21 @@ function wtRevNext() {
|
|
|
1323
1371
|
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1324
1372
|
wtSlideGo(st.to)
|
|
1325
1373
|
}
|
|
1326
|
-
function wtGo(sid, step, { group = null } = {}) {
|
|
1374
|
+
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
1327
1375
|
const node = wtUi.node; if (!node) return
|
|
1328
1376
|
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
1377
|
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
1378
|
const k = wtKey(sid)
|
|
1331
1379
|
const deck = node.querySelector('#wtDeck')
|
|
1332
|
-
|
|
1380
|
+
// 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
|
|
1381
|
+
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 }
|
|
1382
|
+
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
1383
|
wtUi.cur.set(k, step)
|
|
1334
1384
|
for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1335
1385
|
for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1336
1386
|
const el = node.querySelector('#wt-' + CSS.escape(step))
|
|
1337
|
-
|
|
1387
|
+
const row = fileRow()
|
|
1388
|
+
if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
|
|
1338
1389
|
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
1390
|
}
|
|
1340
1391
|
// 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
|
|
@@ -1396,8 +1447,11 @@ function wtAsk(sid, text) {
|
|
|
1396
1447
|
const data = wtUi.data.get(wtKey(sid))
|
|
1397
1448
|
// 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
|
|
1398
1449
|
const picks = wtPicksNow(data)
|
|
1450
|
+
// 2026-11-03 which changes the question is about, as the toggle has it right now
|
|
1451
|
+
const line = scopeAskLine(data)
|
|
1452
|
+
const lead = (m) => (line ? line + '\n' + m : m)
|
|
1399
1453
|
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)
|
|
1454
|
+
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
1455
|
wtUi.picks = []
|
|
1402
1456
|
return wtAskPut(sid, msg)
|
|
1403
1457
|
}
|
|
@@ -1405,7 +1459,7 @@ function wtAsk(sid, text) {
|
|
|
1405
1459
|
const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
|
|
1406
1460
|
const co = st && st.callouts[0]
|
|
1407
1461
|
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
|
|
1462
|
+
const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
|
|
1409
1463
|
wtAskPut(sid, msg)
|
|
1410
1464
|
}
|
|
1411
1465
|
function wtAskPut(sid, msg) {
|
|
@@ -1772,6 +1826,9 @@ function repoRow(shown, f, tail = '') {
|
|
|
1772
1826
|
return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
|
|
1773
1827
|
}
|
|
1774
1828
|
|
|
1829
|
+
// 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
|
|
1830
|
+
// (the counts are per log; a chip's log is its sessions' decisionRepo)
|
|
1831
|
+
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
1832
|
// The repo chip row, shared by the board and the timeline: All, the top 8,
|
|
1776
1833
|
// and More ▾ with a filter box. chips: repoChips() output.
|
|
1777
1834
|
function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
@@ -1782,7 +1839,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1782
1839
|
// with nothing in this view: a filter is never active out of sight.
|
|
1783
1840
|
chips = orderSelectedFirst(chips, selected)
|
|
1784
1841
|
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>' }
|
|
1842
|
+
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
1843
|
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
1844
|
let menu = ''
|
|
1788
1845
|
if (more.length) {
|
|
@@ -1796,13 +1853,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1796
1853
|
// ------------------------------------------------------------ projects (0.9.0)
|
|
1797
1854
|
// A project is a named group of repos (projects.json in the synced folder).
|
|
1798
1855
|
// 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 }
|
|
1856
|
+
const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
|
|
1800
1857
|
async function loadProjects({ fresh = false } = {}) {
|
|
1801
1858
|
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() }
|
|
1859
|
+
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
1860
|
return projectsUi
|
|
1804
1861
|
}
|
|
1805
|
-
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
|
|
1862
|
+
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
1863
|
// The small chip next to the repo: tap = show only this project.
|
|
1807
1864
|
function projChip(s) {
|
|
1808
1865
|
const p = s && s.repoProject
|
|
@@ -1865,7 +1922,7 @@ function projectRow(list, sel, action, counts, tail = '') {
|
|
|
1865
1922
|
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
1923
|
// the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
|
|
1867
1924
|
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>'
|
|
1925
|
+
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
1926
|
}
|
|
1870
1927
|
// 0.9.12: the board's own way in to a project with several repos: a "New
|
|
1871
1928
|
// project" button at the end of the project row (or the repo row before there
|
|
@@ -2542,9 +2599,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
|
|
|
2542
2599
|
const SESSION_CACHE_MAX = 8
|
|
2543
2600
|
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
2544
2601
|
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
2602
|
+
// 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
|
|
2603
|
+
// (the longest whole-word cut that fits, with …), never mid-word by the
|
|
2604
|
+
// ellipsis; the whole name is in its tip.
|
|
2605
|
+
function fitTitleWords(el, text) {
|
|
2606
|
+
el.textContent = text
|
|
2607
|
+
if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
|
|
2608
|
+
if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
|
|
2609
|
+
let lo = 4, hi = text.length, best = ''
|
|
2610
|
+
while (lo <= hi) {
|
|
2611
|
+
const mid = (lo + hi) >> 1
|
|
2612
|
+
el.textContent = cutTitle(text, mid)
|
|
2613
|
+
if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
|
|
2614
|
+
}
|
|
2615
|
+
el.textContent = best || cutTitle(text, lo)
|
|
2616
|
+
}
|
|
2617
|
+
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
|
|
2618
|
+
// 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
|
|
2619
|
+
const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
|
|
2620
|
+
function whereLineHtml(s) {
|
|
2621
|
+
const w = sessionWhere(s)
|
|
2622
|
+
return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
|
|
2623
|
+
}
|
|
2545
2624
|
function sessionShellHtml(s) {
|
|
2546
2625
|
const head = s
|
|
2547
|
-
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1
|
|
2626
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
|
|
2548
2627
|
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
2549
2628
|
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
2629
|
'<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 +2829,7 @@ async function loadWid(id) {
|
|
|
2750
2829
|
paintWid(id)
|
|
2751
2830
|
await Promise.all([
|
|
2752
2831
|
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) }),
|
|
2832
|
+
api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
2754
2833
|
])
|
|
2755
2834
|
ui.loading = false
|
|
2756
2835
|
// a file you had open shows its lines again
|
|
@@ -2762,7 +2841,7 @@ async function loadWidFile(id, p) {
|
|
|
2762
2841
|
const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
|
|
2763
2842
|
if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
|
|
2764
2843
|
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) } }
|
|
2844
|
+
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
2845
|
delete ui.fileLoading[p]
|
|
2767
2846
|
paintWid(id)
|
|
2768
2847
|
}
|
|
@@ -2850,8 +2929,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2850
2929
|
// title is being made, its spinner sits in the cluster.
|
|
2851
2930
|
const fullTitle = sessionTitle(s)
|
|
2852
2931
|
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
|
|
2932
|
+
? '<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>'
|
|
2933
|
+
: '<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
2934
|
const menu = fsMenuItems(s, mine)
|
|
2856
2935
|
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2857
2936
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
@@ -2861,7 +2940,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2861
2940
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2862
2941
|
'<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
2942
|
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>' +
|
|
2943
|
+
'<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
2944
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2866
2945
|
(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
2946
|
'<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 +2974,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2895
2974
|
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2896
2975
|
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2897
2976
|
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
|
-
|
|
2977
|
+
side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
|
|
2978
|
+
decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
|
|
2979
|
+
if (s.decisionRepo) noteSessionDecisions(s)
|
|
2980
|
+
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
|
|
2981
|
+
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
2982
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2900
2983
|
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
2984
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
@@ -3324,10 +3407,64 @@ document.addEventListener('scroll', (e) => {
|
|
|
3324
3407
|
const el = e.target
|
|
3325
3408
|
if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
|
|
3326
3409
|
const side = el.closest('.fs-side')
|
|
3410
|
+
// 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
|
|
3411
|
+
// content under your finger by the head's change in height, which flipped it back (the sheet jumped)
|
|
3412
|
+
if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
|
|
3327
3413
|
fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
|
|
3328
3414
|
if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
|
|
3329
3415
|
}, true)
|
|
3330
3416
|
|
|
3417
|
+
// 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
|
|
3418
|
+
// raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
|
|
3419
|
+
// it. Anything else is the content's own native scroll, untouched.
|
|
3420
|
+
const sheetMq = window.matchMedia('(max-width: 600px)')
|
|
3421
|
+
{
|
|
3422
|
+
let d = null
|
|
3423
|
+
const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
|
|
3424
|
+
document.addEventListener('touchstart', (e) => {
|
|
3425
|
+
d = null
|
|
3426
|
+
if (!sheetMq.matches || e.touches.length !== 1) return
|
|
3427
|
+
const side = e.target.closest && e.target.closest('#fsSide')
|
|
3428
|
+
if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
|
|
3429
|
+
const fromPeek = !!e.target.closest('.fs-peek')
|
|
3430
|
+
const inner = side.querySelector('.fs-side-in')
|
|
3431
|
+
if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
|
|
3432
|
+
// a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
|
|
3433
|
+
for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
|
|
3434
|
+
const p = e.touches[0]
|
|
3435
|
+
d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
|
|
3436
|
+
}, { passive: true })
|
|
3437
|
+
document.addEventListener('touchmove', (e) => {
|
|
3438
|
+
if (!d || e.touches.length !== 1) return
|
|
3439
|
+
const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
|
|
3440
|
+
if (!d.mode) {
|
|
3441
|
+
d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
|
|
3442
|
+
if (!d.mode) return
|
|
3443
|
+
if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
|
|
3444
|
+
d.travel = travel(d.side)
|
|
3445
|
+
d.side.classList.add('dragging')
|
|
3446
|
+
}
|
|
3447
|
+
if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
|
|
3448
|
+
const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
|
|
3449
|
+
d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
|
|
3450
|
+
}, { passive: false })
|
|
3451
|
+
const end = (e, cancel) => {
|
|
3452
|
+
const g = d
|
|
3453
|
+
d = null
|
|
3454
|
+
if (!g || g.mode !== 'drag') return
|
|
3455
|
+
g.side.classList.remove('dragging')
|
|
3456
|
+
g.side.style.transform = ''
|
|
3457
|
+
const p = e.changedTouches && e.changedTouches[0]
|
|
3458
|
+
if (cancel || !p) return
|
|
3459
|
+
if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
|
|
3460
|
+
toggleFsSheet()
|
|
3461
|
+
sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
document.addEventListener('touchend', (e) => end(e, false), { passive: true })
|
|
3465
|
+
document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
|
|
3466
|
+
}
|
|
3467
|
+
const sheetUi = { ignoreClick: 0 }
|
|
3331
3468
|
function toggleFsSheet() {
|
|
3332
3469
|
const side = document.getElementById('fsSide')
|
|
3333
3470
|
if (!side) return
|
|
@@ -3379,21 +3516,99 @@ function setFullClass(on) {
|
|
|
3379
3516
|
applyNav()
|
|
3380
3517
|
}
|
|
3381
3518
|
// 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.
|
|
3519
|
+
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3520
|
+
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3521
|
+
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3522
|
+
const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
|
|
3523
|
+
// 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
|
|
3524
|
+
function fitSheetAside() {
|
|
3525
|
+
const html = document.documentElement, a = document.activeElement
|
|
3526
|
+
const typing = isTypingTarget(a)
|
|
3527
|
+
const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
|
|
3528
|
+
const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
|
|
3529
|
+
html.classList.toggle('lf-typing', aside)
|
|
3530
|
+
}
|
|
3383
3531
|
function fitFullViewport() {
|
|
3384
3532
|
const vv = window.visualViewport
|
|
3385
|
-
const
|
|
3386
|
-
|
|
3533
|
+
const html = document.documentElement, st = html.style
|
|
3534
|
+
fitSheetAside()
|
|
3535
|
+
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
3536
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3388
3537
|
const strip = document.getElementById('tabStrip')
|
|
3389
3538
|
const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
|
|
3390
3539
|
st.setProperty('--lf-bar', bar + 'px')
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
st.
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3540
|
+
const typing = isTypingTarget(document.activeElement)
|
|
3541
|
+
const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
|
|
3542
|
+
if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
|
|
3543
|
+
st.setProperty('--lf-h', fit.h + 'px')
|
|
3544
|
+
st.setProperty('--lf-top', fit.top + 'px')
|
|
3545
|
+
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3546
|
+
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3547
|
+
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3548
|
+
if (typing) keepTypingInView()
|
|
3549
|
+
}
|
|
3550
|
+
// 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
|
|
3551
|
+
// bring it back): while you type, check where the composer really is against what you can see, from
|
|
3552
|
+
// this moment's numbers only, and if it isn't in view move full screen by the difference.
|
|
3553
|
+
// (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
|
|
3554
|
+
function keepTypingInView() {
|
|
3555
|
+
const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
|
|
3556
|
+
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3557
|
+
const box = el.closest('.live-box.full')
|
|
3558
|
+
if (!box) return
|
|
3559
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3560
|
+
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3561
|
+
}
|
|
3562
|
+
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
3563
|
+
// page under it) fits full screen then and looks again a few beats after: a swap's last change can
|
|
3564
|
+
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3565
|
+
function onViewportChange(ev) {
|
|
3566
|
+
fitFullViewport()
|
|
3567
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
|
|
3568
|
+
}
|
|
3569
|
+
function settleWhileTyping(from = 0) {
|
|
3570
|
+
clearTimeout(lfKb.settle)
|
|
3571
|
+
if (from >= SETTLE_WHILE_TYPING_MS.length) return
|
|
3572
|
+
let i = from
|
|
3573
|
+
const step = () => {
|
|
3574
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3575
|
+
fitFullViewport()
|
|
3576
|
+
if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
|
|
3577
|
+
}
|
|
3578
|
+
lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
|
|
3579
|
+
}
|
|
3580
|
+
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
|
|
3581
|
+
window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
|
|
3582
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
|
|
3583
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3584
|
+
document.addEventListener('focusin', (e) => {
|
|
3585
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3586
|
+
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3587
|
+
clearTimeout(lfKb.back); lfKb.blurAt = 0
|
|
3588
|
+
fitSheetAside() // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
|
|
3589
|
+
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3590
|
+
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3591
|
+
settleWhileTyping(0)
|
|
3592
|
+
})
|
|
3593
|
+
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3594
|
+
// goes down, whatever iOS reports or doesn't on the way.
|
|
3595
|
+
document.addEventListener('focusout', (e) => {
|
|
3596
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3597
|
+
clearTimeout(lfKb.settle)
|
|
3598
|
+
if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
|
|
3599
|
+
lfKb.blurAt = Date.now()
|
|
3600
|
+
clearTimeout(lfKb.back)
|
|
3601
|
+
lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
|
|
3602
|
+
}
|
|
3603
|
+
let i = 0
|
|
3604
|
+
const step = () => {
|
|
3605
|
+
fitFullViewport()
|
|
3606
|
+
if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
|
|
3607
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3608
|
+
else lfKb.scroll = null
|
|
3609
|
+
}
|
|
3610
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3611
|
+
})
|
|
3397
3612
|
function fullAfterRender(sid) {
|
|
3398
3613
|
const box = document.querySelector('.live-box.full')
|
|
3399
3614
|
setFullClass(!!box)
|
|
@@ -3401,7 +3616,9 @@ function fullAfterRender(sid) {
|
|
|
3401
3616
|
if (box) {
|
|
3402
3617
|
const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
|
|
3403
3618
|
const t = box.querySelector('.lf-title')
|
|
3404
|
-
if (t) t
|
|
3619
|
+
if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
|
|
3620
|
+
const w = box.querySelector('.lf-where')
|
|
3621
|
+
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
3622
|
paintLfTabs()
|
|
3406
3623
|
const ol = box.querySelector('.live-log')
|
|
3407
3624
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
@@ -3638,11 +3855,40 @@ function composerFor(el) {
|
|
|
3638
3855
|
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
3639
3856
|
return box && !box.classList.contains('off') ? box : null
|
|
3640
3857
|
}
|
|
3858
|
+
// The picker the paperclip opens lives outside the page (0.9.18). A phone's
|
|
3859
|
+
// photo picker covers the board for seconds; coming back is a wake-up (focus
|
|
3860
|
+
// after more than AWAY_MS away) that redraws the page, and iOS hands the
|
|
3861
|
+
// picked photo over only after that, to the <input> it opened from. A picker
|
|
3862
|
+
// inside the composer was gone by then (the redraw made a new one), so its
|
|
3863
|
+
// change event went nowhere and the photo never showed. This one is never
|
|
3864
|
+
// redrawn; the composer's own hidden input stays for drops and tests.
|
|
3865
|
+
let attPicker = null
|
|
3866
|
+
function attachPicker() {
|
|
3867
|
+
if (attPicker && attPicker.isConnected) return attPicker
|
|
3868
|
+
attPicker = document.createElement('input')
|
|
3869
|
+
attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
|
|
3870
|
+
attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
|
|
3871
|
+
attPicker.setAttribute('aria-hidden', 'true')
|
|
3872
|
+
document.body.appendChild(attPicker)
|
|
3873
|
+
return attPicker
|
|
3874
|
+
}
|
|
3875
|
+
function openPicker(sid) {
|
|
3876
|
+
if (!sid) return
|
|
3877
|
+
const pk = attachPicker()
|
|
3878
|
+
pk.dataset.sid = sid
|
|
3879
|
+
pk.value = ''
|
|
3880
|
+
pk.click() // still inside the tap: iOS opens a picker only from a user gesture
|
|
3881
|
+
}
|
|
3882
|
+
function takePicked(inp) {
|
|
3883
|
+
const files = Array.from(inp.files || [])
|
|
3884
|
+
if (!files.length) return
|
|
3885
|
+
inp.value = '' // the same file can be picked again
|
|
3886
|
+
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) }
|
|
3887
|
+
}
|
|
3641
3888
|
document.addEventListener('change', (ev) => {
|
|
3642
3889
|
const inp = ev.target
|
|
3643
3890
|
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
3644
|
-
|
|
3645
|
-
inp.value = '' // the same file can be picked again
|
|
3891
|
+
takePicked(inp)
|
|
3646
3892
|
})
|
|
3647
3893
|
document.addEventListener('paste', (ev) => {
|
|
3648
3894
|
const t = ev.target
|
|
@@ -3836,6 +4082,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
|
|
|
3836
4082
|
function liveAfterRender(sid) {
|
|
3837
4083
|
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
3838
4084
|
bangAfterRender()
|
|
4085
|
+
slashAfterRender()
|
|
3839
4086
|
const r = liveKeeper.after($app)
|
|
3840
4087
|
hydrateAttThumbs(); barWidths($app)
|
|
3841
4088
|
fullAfterRender(sid)
|
|
@@ -4259,6 +4506,91 @@ function bangAfterRender() {
|
|
|
4259
4506
|
if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
|
|
4260
4507
|
}
|
|
4261
4508
|
}
|
|
4509
|
+
// ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
|
|
4510
|
+
// The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
|
|
4511
|
+
const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
|
|
4512
|
+
function slashFor(ta) {
|
|
4513
|
+
const box = ta && ta.closest('.live-box .composer')
|
|
4514
|
+
if (!box || ta.disabled) return null
|
|
4515
|
+
if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
|
|
4516
|
+
return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
|
|
4517
|
+
}
|
|
4518
|
+
function paintSlash(ta, { keep = false } = {}) {
|
|
4519
|
+
const box = ta && ta.closest('.composer')
|
|
4520
|
+
if (!box) return null
|
|
4521
|
+
const menu = slashFor(ta)
|
|
4522
|
+
let el = box.querySelector('.slash-menu')
|
|
4523
|
+
if (!menu) {
|
|
4524
|
+
if (el) el.remove()
|
|
4525
|
+
box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
|
|
4526
|
+
slashUi.active = -1
|
|
4527
|
+
return null
|
|
4528
|
+
}
|
|
4529
|
+
if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
|
|
4530
|
+
const id = 'slashMenu-' + box.dataset.sid
|
|
4531
|
+
const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
|
|
4532
|
+
if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
|
|
4533
|
+
box.classList.add('has-slash')
|
|
4534
|
+
ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
|
|
4535
|
+
if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
|
|
4536
|
+
const on = box.querySelector('.slash-opt[aria-selected="true"]')
|
|
4537
|
+
if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
|
|
4538
|
+
return menu
|
|
4539
|
+
}
|
|
4540
|
+
function pickSlash(ta, value) {
|
|
4541
|
+
ta.value = value
|
|
4542
|
+
try { ta.setSelectionRange(value.length, value.length) } catch {}
|
|
4543
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
|
|
4544
|
+
ta.focus({ preventScroll: true })
|
|
4545
|
+
}
|
|
4546
|
+
document.addEventListener('input', (ev) => {
|
|
4547
|
+
const ta = ev.target
|
|
4548
|
+
if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
|
|
4549
|
+
if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
|
|
4550
|
+
paintSlash(ta)
|
|
4551
|
+
})
|
|
4552
|
+
// before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
|
|
4553
|
+
window.addEventListener('keydown', (ev) => {
|
|
4554
|
+
const ta = ev.target
|
|
4555
|
+
if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
4556
|
+
const box = ta.closest('.live-box .composer')
|
|
4557
|
+
if (!box || !box.querySelector('.slash-menu')) return
|
|
4558
|
+
const k = slashKey(ev)
|
|
4559
|
+
if (!k) return
|
|
4560
|
+
const menu = slashFor(ta)
|
|
4561
|
+
if (!menu) return
|
|
4562
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
4563
|
+
if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
|
|
4564
|
+
if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
|
|
4565
|
+
const it = menu.items[slashUi.active]
|
|
4566
|
+
if (it && !it.off) pickSlash(ta, it.value)
|
|
4567
|
+
}, true)
|
|
4568
|
+
// a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
|
|
4569
|
+
// for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
|
|
4570
|
+
function pickSlashOpt(o) {
|
|
4571
|
+
if (!o || o.getAttribute('aria-disabled') === 'true') return false
|
|
4572
|
+
const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
|
|
4573
|
+
if (ta) pickSlash(ta, o.dataset.value)
|
|
4574
|
+
return !!ta
|
|
4575
|
+
}
|
|
4576
|
+
let slashTap = null
|
|
4577
|
+
document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
|
|
4578
|
+
document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
|
|
4579
|
+
document.addEventListener('touchstart', (ev) => {
|
|
4580
|
+
const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
|
|
4581
|
+
slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
|
|
4582
|
+
}, { passive: true })
|
|
4583
|
+
document.addEventListener('touchend', (ev) => {
|
|
4584
|
+
const t = slashTap
|
|
4585
|
+
slashTap = null
|
|
4586
|
+
const p = ev.changedTouches && ev.changedTouches[0]
|
|
4587
|
+
if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
|
|
4588
|
+
if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
|
|
4589
|
+
}, { passive: false })
|
|
4590
|
+
function slashAfterRender() {
|
|
4591
|
+
const ta = document.activeElement
|
|
4592
|
+
if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
|
|
4593
|
+
}
|
|
4262
4594
|
async function liveSendClick(btn) {
|
|
4263
4595
|
const box = btn.closest('.live-box')
|
|
4264
4596
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -4385,10 +4717,11 @@ async function aroundWire(id) {
|
|
|
4385
4717
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4386
4718
|
}
|
|
4387
4719
|
}
|
|
4388
|
-
|
|
4720
|
+
// decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
|
|
4721
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
|
|
4389
4722
|
return list.map((cp) =>
|
|
4390
4723
|
'<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 }) +
|
|
4724
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
|
|
4392
4725
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4393
4726
|
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4394
4727
|
(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 +4731,268 @@ function cpHeadLine(cp) {
|
|
|
4398
4731
|
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
4732
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4400
4733
|
}
|
|
4734
|
+
// 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
|
|
4735
|
+
const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4736
|
+
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4737
|
+
// 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
|
|
4738
|
+
const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
|
|
4739
|
+
const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
|
|
4740
|
+
// The Decided fold: every decision the session made, shown whenever there's at least one
|
|
4741
|
+
function panelDecidedFold(s) {
|
|
4742
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4743
|
+
if (!list.length) return ''
|
|
4744
|
+
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 })
|
|
4745
|
+
}
|
|
4746
|
+
// 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
|
|
4747
|
+
function noteSessionDecisions(s) {
|
|
4748
|
+
const repo = s && s.decisionRepo
|
|
4749
|
+
if (!repo || decisionsUi.loadedRepo[repo]) return
|
|
4750
|
+
decisionsUi.loadedRepo[repo] = true
|
|
4751
|
+
api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
|
|
4752
|
+
decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
|
|
4753
|
+
const ps = panelSess()
|
|
4754
|
+
if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
|
|
4755
|
+
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4756
|
+
}
|
|
4757
|
+
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
4758
|
+
const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
|
|
4759
|
+
const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4760
|
+
async function viewTestplan(arg, params) {
|
|
4761
|
+
clearTimeout(tpUi.timer)
|
|
4762
|
+
if (arg === 'new') {
|
|
4763
|
+
const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
|
|
4764
|
+
const key = JSON.stringify(q)
|
|
4765
|
+
if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
|
|
4766
|
+
paintTestplanNew()
|
|
4767
|
+
const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
|
|
4768
|
+
try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
|
|
4769
|
+
try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
|
|
4770
|
+
paintTestplanNew()
|
|
4771
|
+
const w = tpUi.nu.preview && tpUi.nu.preview.walking
|
|
4772
|
+
if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
|
|
4773
|
+
return
|
|
4774
|
+
}
|
|
4775
|
+
const machine = params.get('machine') || null
|
|
4776
|
+
if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
|
|
4777
|
+
tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
|
|
4778
|
+
paintTestplan()
|
|
4779
|
+
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)
|
|
4780
|
+
}
|
|
4781
|
+
function paintTestplanNew() {
|
|
4782
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
|
|
4783
|
+
const n = tpUi.nu
|
|
4784
|
+
$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>' : '')
|
|
4785
|
+
}
|
|
4786
|
+
function paintTestplan() {
|
|
4787
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
|
|
4788
|
+
$app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
|
|
4789
|
+
}
|
|
4790
|
+
async function tpCheck(id, check, body) {
|
|
4791
|
+
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)) }
|
|
4792
|
+
}
|
|
4793
|
+
document.addEventListener('click', async (ev) => {
|
|
4794
|
+
const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
|
|
4795
|
+
if (!b || b.tagName === 'FORM' || b.disabled) return
|
|
4796
|
+
const a = b.dataset.action
|
|
4797
|
+
if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
|
|
4798
|
+
if (a === 'tp-run' || a === 'tp-walk') {
|
|
4799
|
+
const n = tpUi.nu
|
|
4800
|
+
if (!n) return
|
|
4801
|
+
const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
|
|
4802
|
+
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 }
|
|
4803
|
+
n.starting = true; paintTestplanNew()
|
|
4804
|
+
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() }
|
|
4805
|
+
return
|
|
4806
|
+
}
|
|
4807
|
+
const id = b.dataset.id, check = b.dataset.check
|
|
4808
|
+
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 }) }
|
|
4809
|
+
if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
|
|
4810
|
+
if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
|
|
4811
|
+
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 }
|
|
4812
|
+
if (a === 'tp-download') {
|
|
4813
|
+
if (!tpUi.plan) return
|
|
4814
|
+
const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
|
|
4815
|
+
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()
|
|
4816
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
4817
|
+
return
|
|
4818
|
+
}
|
|
4819
|
+
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)) } }
|
|
4820
|
+
})
|
|
4821
|
+
document.addEventListener('submit', async (ev) => {
|
|
4822
|
+
const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
|
|
4823
|
+
if (!f) return
|
|
4824
|
+
ev.preventDefault()
|
|
4825
|
+
if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
|
|
4826
|
+
if (f.dataset.action === 'tp-propose-save') {
|
|
4827
|
+
try {
|
|
4828
|
+
const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
|
|
4829
|
+
tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
|
|
4830
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4831
|
+
}
|
|
4832
|
+
})
|
|
4833
|
+
// 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
|
|
4834
|
+
// count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
|
|
4835
|
+
function paintPanelDecided(s) {
|
|
4836
|
+
const slot = document.getElementById('fsDecided')
|
|
4837
|
+
if (!slot || !s) return
|
|
4838
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4839
|
+
const fold = slot.querySelector('[data-fold="cp-decided"]')
|
|
4840
|
+
if (!list.length) { if (fold) slot.innerHTML = ''; return }
|
|
4841
|
+
if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
|
|
4842
|
+
const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
|
|
4843
|
+
if (n && n.textContent !== cnt) n.textContent = cnt
|
|
4844
|
+
const box = fold.querySelector('.fs-fold-b')
|
|
4845
|
+
const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4846
|
+
if (box && box.innerHTML !== html) box.innerHTML = html
|
|
4847
|
+
// the Timeline's older checkpoints read the same log
|
|
4848
|
+
const by = sessionLogBy(s)
|
|
4849
|
+
if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
|
|
4850
|
+
const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
|
|
4851
|
+
if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4855
|
+
function refreshPanelDecisions(s) {
|
|
4856
|
+
if (!s || !s.decisionRepo) return
|
|
4857
|
+
decisionsUi.loadedRepo[s.decisionRepo] = false
|
|
4858
|
+
noteSessionDecisions(s)
|
|
4859
|
+
}
|
|
4860
|
+
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4861
|
+
async function viewDecisions(arg) {
|
|
4862
|
+
const raw = decodeURIComponent(arg || '')
|
|
4863
|
+
if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
|
|
4864
|
+
decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
|
|
4865
|
+
const want = state.route && state.route.params && state.route.params.get('d')
|
|
4866
|
+
if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
|
|
4867
|
+
paintDecisions()
|
|
4868
|
+
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) } }
|
|
4869
|
+
}
|
|
4870
|
+
function paintDecisions() {
|
|
4871
|
+
if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
|
|
4872
|
+
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
|
|
4873
|
+
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4874
|
+
}
|
|
4875
|
+
async function decisionAction(repo, ref, action, body = {}) {
|
|
4876
|
+
try {
|
|
4877
|
+
const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4878
|
+
decisionsUi.loadedRepo = {}
|
|
4879
|
+
// a walkthrough citing it shows its new word (Proposed goes once accepted)
|
|
4880
|
+
const dd = r && r.decision
|
|
4881
|
+
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 = '' }
|
|
4882
|
+
if (wtUi.sid) wtFill(wtSession(wtUi.sid))
|
|
4883
|
+
const dv = document.querySelector('.dv-isdec:not([hidden])')
|
|
4884
|
+
if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
|
|
4885
|
+
if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
|
|
4886
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4887
|
+
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4888
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4889
|
+
}
|
|
4890
|
+
document.addEventListener('click', async (ev) => {
|
|
4891
|
+
const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
|
|
4892
|
+
if (!btn || btn.tagName === 'FORM') return
|
|
4893
|
+
const a = btn.dataset.action
|
|
4894
|
+
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4895
|
+
if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
|
|
4896
|
+
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() }
|
|
4897
|
+
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4898
|
+
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4899
|
+
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 }
|
|
4900
|
+
if (a === 'dec-show') {
|
|
4901
|
+
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
4902
|
+
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 }
|
|
4903
|
+
if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
|
|
4904
|
+
return
|
|
4905
|
+
}
|
|
4906
|
+
if (a === 'dec-store') {
|
|
4907
|
+
const on = btn.dataset.on === 'true'
|
|
4908
|
+
try {
|
|
4909
|
+
await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
|
|
4910
|
+
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')
|
|
4911
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4912
|
+
else loadProjectDecisions(qaUi.token, true)
|
|
4913
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4914
|
+
}
|
|
4915
|
+
})
|
|
4916
|
+
// A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
|
|
4917
|
+
document.addEventListener('click', (ev) => {
|
|
4918
|
+
const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
|
|
4919
|
+
if (!b) return
|
|
4920
|
+
ev.preventDefault()
|
|
4921
|
+
const s = wtSession(wtUi.sid)
|
|
4922
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
|
|
4923
|
+
})
|
|
4924
|
+
// 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
|
|
4925
|
+
const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4926
|
+
let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
|
|
4927
|
+
async function openDecisionViewer(repo, id, from, ctx = {}) {
|
|
4928
|
+
if (!repo || !id) return
|
|
4929
|
+
decViewerCtx = { repo, id, from, ctx }
|
|
4930
|
+
let v
|
|
4931
|
+
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4932
|
+
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
4933
|
+
const d = byId.get(id)
|
|
4934
|
+
const el = docViewerEl()
|
|
4935
|
+
el.querySelector('.dv-title').textContent = 'Decision ' + id
|
|
4936
|
+
el.querySelector('.dv-dl').hidden = true
|
|
4937
|
+
el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
|
|
4938
|
+
el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
|
|
4939
|
+
// the chain: what it replaced (oldest first), the decision, what replaced it
|
|
4940
|
+
const chain = []
|
|
4941
|
+
if (d) {
|
|
4942
|
+
const back = []; let cur = d, guard = 0
|
|
4943
|
+
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 }
|
|
4944
|
+
chain.push(...back, d)
|
|
4945
|
+
cur = d; guard = 0
|
|
4946
|
+
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4947
|
+
}
|
|
4948
|
+
// 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
|
|
4949
|
+
// 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
|
|
4950
|
+
const trusted = decisionsTrusted()
|
|
4951
|
+
const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
|
|
4952
|
+
let offer = ''
|
|
4953
|
+
if (d && ctx.departs && ctx.sessionId) {
|
|
4954
|
+
const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
|
|
4955
|
+
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">' +
|
|
4956
|
+
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>'
|
|
4957
|
+
}
|
|
4958
|
+
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 +
|
|
4959
|
+
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4960
|
+
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4961
|
+
refUi.from = from || null
|
|
4962
|
+
el.hidden = false
|
|
4963
|
+
document.documentElement.classList.add('dv-open')
|
|
4964
|
+
const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
|
|
4965
|
+
}
|
|
4966
|
+
document.addEventListener('submit', (ev) => {
|
|
4967
|
+
const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
|
|
4968
|
+
if (!f) return
|
|
4969
|
+
ev.preventDefault()
|
|
4970
|
+
const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
|
|
4971
|
+
decisionsUi.editing = null
|
|
4972
|
+
decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
|
|
4973
|
+
})
|
|
4974
|
+
// The project page's Decisions section
|
|
4975
|
+
// (2026-11-05) or a single repo's page: that repo's log (the page names its id)
|
|
4976
|
+
const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
|
|
4977
|
+
async function loadProjectDecisions(token, force = false) {
|
|
4978
|
+
if (!token) return
|
|
4979
|
+
const scopeArg = pageDecisionScope(token)
|
|
4980
|
+
if (!scopeArg) return
|
|
4981
|
+
try {
|
|
4982
|
+
const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
|
|
4983
|
+
decisionsUi.project = { token, v, scopeArg }
|
|
4984
|
+
paintProjectDecisions()
|
|
4985
|
+
} catch { decisionsUi.project = null }
|
|
4986
|
+
}
|
|
4987
|
+
function paintProjectDecisions() {
|
|
4988
|
+
const box = document.getElementById('projDecisionsBox')
|
|
4989
|
+
const pd = decisionsUi.project
|
|
4990
|
+
if (!box || !pd || pd.token !== qaUi.token) return
|
|
4991
|
+
const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
|
|
4992
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
|
|
4993
|
+
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4994
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
|
|
4995
|
+
}
|
|
4401
4996
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4402
4997
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
4403
4998
|
function cpLatestHtml(cp, wtSid, paths) {
|
|
@@ -4425,10 +5020,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
|
4425
5020
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
4426
5021
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
4427
5022
|
// 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: {} }
|
|
5023
|
+
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
5024
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
4430
5025
|
// 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 } }
|
|
5026
|
+
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
5027
|
function paintPrs(d) {
|
|
4433
5028
|
const q = document.getElementById('prs-q')
|
|
4434
5029
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -4577,7 +5172,8 @@ function briefAction(id, sessions, viewer) {
|
|
|
4577
5172
|
|
|
4578
5173
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4579
5174
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
4580
|
-
|
|
5175
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
5176
|
+
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
5177
|
const out = []
|
|
4582
5178
|
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4583
5179
|
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
@@ -6601,6 +7197,8 @@ async function render() {
|
|
|
6601
7197
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
6602
7198
|
else if (r.name === 'new') await viewNewSession()
|
|
6603
7199
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
7200
|
+
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
7201
|
+
else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
|
|
6604
7202
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6605
7203
|
else if (r.name === 'setup') await viewSetup()
|
|
6606
7204
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -6635,6 +7233,7 @@ async function viewProject(token) {
|
|
|
6635
7233
|
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
7234
|
qaUi.page = page
|
|
6637
7235
|
paintProject()
|
|
7236
|
+
if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
|
|
6638
7237
|
if (fresh) window.scrollTo(0, 0)
|
|
6639
7238
|
const r = state.route || {}
|
|
6640
7239
|
if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
|
|
@@ -6654,6 +7253,11 @@ function paintProject() {
|
|
|
6654
7253
|
const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
6655
7254
|
$app.innerHTML = out.html
|
|
6656
7255
|
fillAsk($app, out.keys)
|
|
7256
|
+
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
|
|
7257
|
+
const at = document.getElementById('projAsks')
|
|
7258
|
+
if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
|
|
7259
|
+
paintProjectDecisions()
|
|
7260
|
+
}
|
|
6657
7261
|
const ta = document.getElementById('askIn')
|
|
6658
7262
|
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
7263
|
}
|
|
@@ -8485,7 +9089,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8485
9089
|
// 0.9.16 the session panel (ui/fs-side.js)
|
|
8486
9090
|
if (action === 'fs-side') return toggleFsSide()
|
|
8487
9091
|
if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
|
|
8488
|
-
if (action === 'fs-sheet') return toggleFsSheet()
|
|
9092
|
+
if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
|
|
8489
9093
|
if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
|
|
8490
9094
|
if (action === 'tab-pin') return togglePin(id)
|
|
8491
9095
|
// 0.9.17 Walk me through it
|
|
@@ -8493,6 +9097,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8493
9097
|
if (action === 'wt-close') return wtClose()
|
|
8494
9098
|
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
9099
|
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) }
|
|
9100
|
+
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
8496
9101
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8497
9102
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
|
8498
9103
|
if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
|
|
@@ -8523,7 +9128,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8523
9128
|
const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
|
|
8524
9129
|
return
|
|
8525
9130
|
}
|
|
8526
|
-
if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
|
|
9131
|
+
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
9132
|
if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
|
|
8528
9133
|
// 2026-10-30 walkthrough round 3
|
|
8529
9134
|
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 +9188,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8583
9188
|
// 0.9.8 What it did
|
|
8584
9189
|
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
9190
|
if (action === 'wid-refresh') return loadWid(id)
|
|
9191
|
+
if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
|
|
8586
9192
|
if (action === 'wid-tool') {
|
|
8587
9193
|
const ui = widOf(id), t = btn.dataset.tool
|
|
8588
9194
|
ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
|
|
@@ -8623,6 +9229,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
8623
9229
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
8624
9230
|
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
8625
9231
|
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
9232
|
+
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
|
|
9233
|
+
if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
|
|
8626
9234
|
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
9235
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
8628
9236
|
if (action === 'pr-unlink') {
|
|
@@ -9151,7 +9759,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9151
9759
|
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
9152
9760
|
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
9153
9761
|
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.
|
|
9762
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
|
|
9155
9763
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
9156
9764
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
9157
9765
|
if (action === 'att-open') return openAttachment(btn)
|
|
@@ -9491,7 +10099,7 @@ function docViewerEl() {
|
|
|
9491
10099
|
document.body.appendChild(el)
|
|
9492
10100
|
return el
|
|
9493
10101
|
}
|
|
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') }
|
|
10102
|
+
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
10103
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
9496
10104
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
9497
10105
|
if (!sessionId || (!path && !plan)) return
|