@leftoff/cli 0.9.17 → 0.9.18-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -2
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +794 -540
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +186 -5
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +240 -2
- package/ui/app.js +553 -62
- package/ui/ask.js +6 -3
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +235 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +23 -6
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +165 -22
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/start.js +87 -12
- package/ui/summary-examples.js +2 -2
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +59 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
package/ui/app.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
+
import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
|
|
5
6
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
|
|
6
7
|
import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
|
|
7
8
|
import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
|
|
@@ -11,7 +12,7 @@ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
|
|
|
11
12
|
import { changedKeys } from './settings-dirty.js'
|
|
12
13
|
import { looksMd, stripMd, plainCp, tidyCp } from './cp-text.js'
|
|
13
14
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
14
|
-
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
15
|
+
import { repoKeyOf, inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel, repoDisplay } from './repos.js'
|
|
15
16
|
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
16
17
|
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
17
18
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
@@ -23,7 +24,7 @@ import { parseBang, dropBang, ptyInput, commandOutput, outputMessage, createBang
|
|
|
23
24
|
import { withFresh, pickFresh, newerFresh, freshWins, pendingHeadless, settled as headlessSettled } from './headless-fresh.js' // 0.9.9 another Mac's session, current after Stop/Resume/Permissions
|
|
24
25
|
import { bgPillHtml } from './bgtasks.js' // 0.9.13 "2 tasks running"
|
|
25
26
|
import { scopeHtml, scopeHintHtml, scopeStart } from './sm-scope.js' // 0.9.15 per-machine summarizer
|
|
26
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
27
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, hmHelpId, modePillHtml, termPillHtml, termBarHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved, startBody, startExtrasNote, startProjectLineHtml, START_SID, startAttachTodo, newDraftId } from './start.js' // 0.9.9 New session
|
|
27
28
|
import { parseNewSession, projectStartItems, newSessionContext, startStateFor } from './cmdk-new.js' // 0.9.13 ⌘K New session…
|
|
28
29
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
29
30
|
import { wireTips } from './tips.js'
|
|
@@ -39,13 +40,15 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
|
|
|
39
40
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
40
41
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
41
42
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
43
|
+
import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
|
|
42
44
|
import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
|
|
43
45
|
import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
|
|
44
46
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
|
|
45
47
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
46
48
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
47
49
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
48
|
-
import {
|
|
50
|
+
import { cutTitle } from './session-title.js'
|
|
51
|
+
import { sessionTitle, sessionWhere, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, scrollDecision, atBottom, jumpView, fadeBelow, jumpTarget, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted, pastViewWanted, pastEmptyText, parseFullRoute, fullHref, pageHref, fullButtonHtml, fullBarHtml, retryPayload, editDraft, messageAttachments, createRetryGate } from './live-view.js' // 0.9.8 Live link polish
|
|
49
52
|
import { statusView, elapsedText } from './live-status.js'
|
|
50
53
|
import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
|
|
51
54
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
@@ -54,7 +57,7 @@ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember
|
|
|
54
57
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
55
58
|
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
56
59
|
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
57
|
-
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT } from './semdiff.js' // 0.9.17 Walk me through it
|
|
60
|
+
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
|
|
58
61
|
import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-lines.js' // 2026-10-30 line refs for Ask
|
|
59
62
|
import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
|
|
60
63
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
@@ -73,6 +76,9 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
|
|
|
73
76
|
import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
|
|
74
77
|
import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
|
|
75
78
|
import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
|
|
79
|
+
import { testplanPageHtml, testplanNewHtml, planMarkdown, planHtmlDoc, testplanHref } from './testplan.js' // testplan Part 3
|
|
80
|
+
import { cpFoldSummary } from './cp-fold.js' // 2026-11-05 the fold names what's inside
|
|
81
|
+
import { decidedListHtml, sessionDecisions, sessionDecisionsCount, logBySession, decisionsGivenHtml, reviewCountHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
|
|
76
82
|
import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
|
|
77
83
|
import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
|
|
78
84
|
import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
|
|
@@ -468,15 +474,38 @@ const liveBadge = (s) => (ownerOffline(s) ? '' : liveBadgeHtml(s)) // 0.9.14: an
|
|
|
468
474
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
469
475
|
// 0.9.14: another Mac that's asleep or unreachable: "offline · last seen 12m ago" instead (never "needs you").
|
|
470
476
|
const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st = cardPills(s).status; return st ? pill(st, s) : '' }
|
|
477
|
+
// 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
|
|
478
|
+
// are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
|
|
479
|
+
const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
|
|
480
|
+
// 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
|
|
481
|
+
// sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
|
|
482
|
+
const hooksQuietHtml = (s) => {
|
|
483
|
+
if (!s || s.endedAt) return ''
|
|
484
|
+
const k = s.hooksSkipped
|
|
485
|
+
if (k && k.label) return '<span class="s-hooks s-hooks-skip' + (k.applied ? ' s-hooks-applied' : '') + '" data-tip="' + esc(k.tip || k.label) + '"><span class="s-hooks-ring" aria-hidden="true"></span><span class="s-hooks-t">' + esc(k.label) + '</span></span>' + hooksFixButtonHtml(s, { esc }) // 2026-11-03 applied: "restart Claude Code to apply", no button
|
|
486
|
+
return s.hooksQuiet ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : ''
|
|
487
|
+
}
|
|
488
|
+
// the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
|
|
489
|
+
document.addEventListener('click', (e) => {
|
|
490
|
+
const b = e.target.closest && e.target.closest('[data-hookfix]')
|
|
491
|
+
if (!b) return
|
|
492
|
+
e.preventDefault()
|
|
493
|
+
const sid = b.dataset.hookfix
|
|
494
|
+
const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
|
|
495
|
+
openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
|
|
496
|
+
})
|
|
471
497
|
const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
|
|
472
498
|
|
|
473
499
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
474
500
|
|
|
475
501
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
476
|
-
const EXTRA_TITLES = [['decisions', '
|
|
502
|
+
const EXTRA_TITLES = [['decisions', 'Approach notes'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
477
503
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
478
|
-
|
|
479
|
-
|
|
504
|
+
// decided ({ log }, 2026-11-05): an older checkpoint in the Timeline also lists its own Decided lines, read-only
|
|
505
|
+
const decidedHere = (cp, decided) => !!(decided && cp && Array.isArray(cp.decided) && cp.decided.length)
|
|
506
|
+
const decidedSecHtml = (cp, log) => '<div class="cpx-sec cpx-decided" data-cp="' + esc(cp.id || '') + '"><div class="cpx-h">Decided</div>' + decidedListHtml(cp.decided, { esc, log, readOnly: true }) + '</div>'
|
|
507
|
+
function cpExtras(cp, paths = null, decided = null) {
|
|
508
|
+
let h = decidedHere(cp, decided) ? decidedSecHtml(cp, decided.log) : ''
|
|
480
509
|
for (const [k, title] of EXTRA_TITLES) {
|
|
481
510
|
const v = cp[k]
|
|
482
511
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
@@ -495,11 +524,11 @@ function cpExtras(cp, paths = null) {
|
|
|
495
524
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
496
525
|
// part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
|
|
497
526
|
// paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
|
|
498
|
-
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
|
|
527
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null, decided = null } = {}) {
|
|
499
528
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
500
529
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
501
530
|
const you = /^YOU:/i.test(cp.next || '')
|
|
502
|
-
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
531
|
+
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length + (decidedHere(cp, decided) ? 1 : 0)
|
|
503
532
|
const dd = (t, cls = '') => {
|
|
504
533
|
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
505
534
|
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
@@ -518,7 +547,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
518
547
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
519
548
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · ' + (cp.generator.by === 'heuristic' ? '' : 'by ') + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) : '') + '</div>' : '') +
|
|
520
549
|
'</dl>' +
|
|
521
|
-
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp
|
|
550
|
+
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + esc(cpFoldSummary(cp, { decided: decidedHere(cp, decided) })) + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths, decided) + '</div></details>' : '')
|
|
522
551
|
}
|
|
523
552
|
|
|
524
553
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -529,7 +558,7 @@ function agentChip(a, { title } = {}) {
|
|
|
529
558
|
|
|
530
559
|
// 0.8.0: a Codex session wears a small "Codex" badge; Claude Code's wear none.
|
|
531
560
|
function codexBadge(s) {
|
|
532
|
-
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex"
|
|
561
|
+
return s && s.codingAgent === 'codex' ? '<span class="chip agent-badge codex" data-tip="' + esc('OpenAI Codex session' + (s.surface === 'codex-ide' ? ' (IDE)' : s.surface === 'codex-app' ? ' (app)' : '')) + '">Codex</span>' : ''
|
|
533
562
|
}
|
|
534
563
|
|
|
535
564
|
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
@@ -1088,7 +1117,7 @@ function card(s) {
|
|
|
1088
1117
|
'<div class="row1"><div class="title">' + cardGlyph(s) +
|
|
1089
1118
|
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
1090
1119
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
1091
|
-
'</div>' + statusPill(s) + liveBadge(s) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1120
|
+
'</div>' + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + eyeCountHtml(s.lookCount, { esc }) + '</div>' +
|
|
1092
1121
|
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln && !off ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
1093
1122
|
(s.needsInput && s.lastNotification && !s.needsYou && !off ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
1094
1123
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + recapLineHtml(s, { esc, ago, clock }) + cardCheckpoint(s) + beforeClearHtml(s, { esc }) +
|
|
@@ -1104,7 +1133,11 @@ function card(s) {
|
|
|
1104
1133
|
// own node is kept between the page's redraws (the live poll repaints the page
|
|
1105
1134
|
// every few seconds) so the scroll, the drawn code and the open groups stay.
|
|
1106
1135
|
// Mark reviewed is the session's own record (session.md, like a pin).
|
|
1107
|
-
|
|
1136
|
+
// 2026-11-03 This session · Whole folder: one choice on this device for the walkthrough, its Files view, What it did and Ask
|
|
1137
|
+
const fileScope = fileScopeStore((() => { try { return localStorage } catch { return null } })())
|
|
1138
|
+
const filesQ = (sep = '?') => (fileScope.get() === 'folder' ? sep + 'files=folder' : '')
|
|
1139
|
+
function wtBody(sid, extra = {}) { return { scope: wtUi.scope.get(sid) || 'session', ...(fileScope.get() === 'folder' ? { files: 'folder' } : {}), ...extra } }
|
|
1140
|
+
function wtKey(sid) { return sid + '|' + (wtUi.scope.get(sid) || 'session') + (fileScope.get() === 'folder' ? '|folder' : '') }
|
|
1108
1141
|
function wtSession(sid) { const c = sessionCache.get(sid); return (c && c.s) || state.sessions.find((x) => x.id === sid) || null }
|
|
1109
1142
|
function wtRemember() { try { if (wtUi.sid) sessionStorage.setItem('lo-wt', JSON.stringify({ sid: wtUi.sid, scope: wtUi.scope.get(wtUi.sid) || 'session', view: wtUi.view })); else sessionStorage.removeItem('lo-wt') } catch {} }
|
|
1110
1143
|
function wtRestore(sid) {
|
|
@@ -1135,14 +1168,27 @@ async function wtCheck(sid) {
|
|
|
1135
1168
|
if (wtUi.checking.has(k)) return
|
|
1136
1169
|
wtUi.checking.add(k)
|
|
1137
1170
|
try {
|
|
1138
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1171
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { stale: true, peek: true }), { timeoutMs: 60000 })
|
|
1139
1172
|
const had = wtUi.data.get(k)
|
|
1140
1173
|
if (r && r.status === 'ok' && had && !wtUi.loading.has(k) && (r.fingerprint !== had.fingerprint || JSON.stringify(r.stale || null) !== JSON.stringify(had.stale || null))) {
|
|
1174
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1141
1175
|
wtUi.data.set(k, r)
|
|
1142
1176
|
if (wtUi.sid === sid) { wtUi.sig = ''; wtFill(wtSession(sid)) }
|
|
1143
1177
|
}
|
|
1144
1178
|
} catch {} finally { wtUi.checking.delete(k) }
|
|
1145
1179
|
}
|
|
1180
|
+
// 2026-11-03 This session · Whole folder: kept on this device; the walkthrough's story goes stale (Update writes
|
|
1181
|
+
// it again, never a model run on its own: peek), its Files view and What it did show the other side at once
|
|
1182
|
+
function setFileScope(v, { id = null } = {}) {
|
|
1183
|
+
const want = v === 'folder' ? 'folder' : 'session'
|
|
1184
|
+
if (fileScope.get() === want) return
|
|
1185
|
+
fileScope.set(want)
|
|
1186
|
+
wtUi.filter = 'all'; wtUi.picks = []
|
|
1187
|
+
for (const [k, z] of wtSize) { if (!z.pending) { z.at = 0; wtSizeFetch(k) } }
|
|
1188
|
+
for (const [k, w] of widUi) { if (w.open && w.diff) loadWid(k); else if (w.diff) Object.assign(w, { tools: null, diff: null, files: {} }) }
|
|
1189
|
+
if (wtUi.sid) { wtUi.sig = ''; wtLoad(wtUi.sid, { peek: true }) }
|
|
1190
|
+
else if (id) repaintSession(id)
|
|
1191
|
+
}
|
|
1146
1192
|
function wtClose() {
|
|
1147
1193
|
const sid = wtUi.sid
|
|
1148
1194
|
document.documentElement.classList.remove('wt-open')
|
|
@@ -1165,7 +1211,7 @@ async function wtSizeFetch(sid) {
|
|
|
1165
1211
|
e.pending = true; wtSize.set(sid, e)
|
|
1166
1212
|
const sc = wtUi.scope.get(sid)
|
|
1167
1213
|
let data = e.data
|
|
1168
|
-
try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc : '')) } catch { data = e.data || null } // an older Mac: no line
|
|
1214
|
+
try { data = await api('GET', '/v1/sessions/' + encodeURIComponent(sid) + '/change-size' + (sc === 'checkpoint' || sc === 'session' ? '?scope=' + sc + filesQ('&') : filesQ('?'))) } catch { data = e.data || null } // an older Mac: no line
|
|
1169
1215
|
wtSize.set(sid, { at: Date.now(), data, pending: false })
|
|
1170
1216
|
for (const slot of document.querySelectorAll('.fs-wt-size[data-id="' + CSS.escape(sid) + '"]')) {
|
|
1171
1217
|
slot.innerHTML = sizeLineHtml(data, { esc, icon, reviewed: wtRevCount(wtSession(sid), data) })
|
|
@@ -1190,7 +1236,7 @@ let loPop = null
|
|
|
1190
1236
|
function wtFallBack(sid) { wtUi.scope.set(sid, 'session'); wtUi.fellBack.add(sid); wtRemember() }
|
|
1191
1237
|
// 2026-10-30 stale: true: with nothing written for this exact diff, the daemon hands back the newest
|
|
1192
1238
|
// walkthrough it kept for this session and scope, marked stale, instead of a model run (Update: update)
|
|
1193
|
-
async function wtLoad(sid, { fresh = false, update = false } = {}) {
|
|
1239
|
+
async function wtLoad(sid, { fresh = false, update = false, peek = false } = {}) {
|
|
1194
1240
|
const k = wtKey(sid)
|
|
1195
1241
|
const had = wtUi.data.get(k)
|
|
1196
1242
|
if (!fresh && !update && had && had.status === 'empty' && wtUi.scope.get(sid) === 'checkpoint') { wtFallBack(sid); return wtLoad(sid) }
|
|
@@ -1198,7 +1244,8 @@ async function wtLoad(sid, { fresh = false, update = false } = {}) {
|
|
|
1198
1244
|
wtUi.loading.add(k); wtUi.error.delete(k)
|
|
1199
1245
|
wtFill(wtSession(sid))
|
|
1200
1246
|
try {
|
|
1201
|
-
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough',
|
|
1247
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough', wtBody(sid, { ...(fresh ? { fresh: true } : {}), ...(fresh || update ? {} : { stale: true }), ...(peek && !fresh && !update ? { peek: true } : {}) }), { timeoutMs: 480000 }) // the session's agent: up to ~3 min a read
|
|
1248
|
+
if (r && Array.isArray(r.steps)) uniqueStepIds(r.steps) // a walkthrough from an older Mac: two steps could share an id
|
|
1202
1249
|
wtUi.data.set(k, r)
|
|
1203
1250
|
if (r && r.status === 'ok') { const st = (r.steps || [])[0]; if (st && !wtUi.cur.has(k)) wtUi.cur.set(k, st.id) }
|
|
1204
1251
|
// 2026-10-30 a new walkthrough comes with its marks (the ones carried over from the one before)
|
|
@@ -1323,18 +1370,21 @@ function wtRevNext() {
|
|
|
1323
1370
|
if (!done) wtReviewed(sid, step, { force: true, advance: false })
|
|
1324
1371
|
wtSlideGo(st.to)
|
|
1325
1372
|
}
|
|
1326
|
-
function wtGo(sid, step, { group = null } = {}) {
|
|
1373
|
+
function wtGo(sid, step, { group = null, file = null } = {}) {
|
|
1327
1374
|
const node = wtUi.node; if (!node) return
|
|
1328
1375
|
if (group && node.querySelector('#wtDeck')) { const d = node.querySelector('#wtDeck'), sk = d.lastElementChild; if (sk && !sk.dataset.step && sk.dataset.slide !== '0') wtSlideGo(Number(sk.dataset.slide)) }
|
|
1329
1376
|
if (group) { const g = node.querySelector('#wt-g-' + CSS.escape(group)); if (g) { g.open = true; g.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }) } return }
|
|
1330
1377
|
const k = wtKey(sid)
|
|
1331
1378
|
const deck = node.querySelector('#wtDeck')
|
|
1332
|
-
|
|
1379
|
+
// 2026-11-03 "Also in step N": that step's row for the file, opened, is where it lands
|
|
1380
|
+
const fileRow = () => { if (!file) return null; const d = node.querySelector('details.wt-sf[data-sfk="' + CSS.escape(step + '|' + file) + '"]'); if (d && !d.open) d.open = true; return d }
|
|
1381
|
+
if (deck) { const sl = [...deck.children].findIndex((x) => x.dataset.step === step); if (sl >= 0) { wtUi.cur.set(k, step); wtSlideGo(sl); const d = fileRow(); if (d) d.scrollIntoView({ block: 'nearest', behavior: 'auto' }) } return } // 0.9.18 the slideshow: that step's slide
|
|
1333
1382
|
wtUi.cur.set(k, step)
|
|
1334
1383
|
for (const a of node.querySelectorAll('article.wt-st')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1335
1384
|
for (const a of node.querySelectorAll('.wt-fm-i[data-step]')) a.classList.toggle('cur', a.dataset.step === step)
|
|
1336
1385
|
const el = node.querySelector('#wt-' + CSS.escape(step))
|
|
1337
|
-
|
|
1386
|
+
const row = fileRow()
|
|
1387
|
+
if (el) { (row || el).scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); el.focus({ preventScroll: true }) }
|
|
1338
1388
|
const data = wtUi.data.get(k); const n = node.querySelector('#wtPnavN'); if (n && data) n.textContent = stepPos(data.steps, { filter: wtUi.filter, cur: step })
|
|
1339
1389
|
}
|
|
1340
1390
|
// 0.9.18 the walkthrough as slides (ui/semdiff.js deckHtml): "Show all" (kept per device)
|
|
@@ -1396,8 +1446,11 @@ function wtAsk(sid, text) {
|
|
|
1396
1446
|
const data = wtUi.data.get(wtKey(sid))
|
|
1397
1447
|
// 2026-10-30 lines you picked: each ref and its exact lines go in with the question (ui/walk-lines.js)
|
|
1398
1448
|
const picks = wtPicksNow(data)
|
|
1449
|
+
// 2026-11-03 which changes the question is about, as the toggle has it right now
|
|
1450
|
+
const line = scopeAskLine(data)
|
|
1451
|
+
const lead = (m) => (line ? line + '\n' + m : m)
|
|
1399
1452
|
if (picks.length) {
|
|
1400
|
-
const msg = refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text)
|
|
1453
|
+
const msg = lead(refsPrompt(describePicks(picks, (p) => { const x = patchFor(data, p.k); return x && { rows: rowsOf(x.patch), label: x.label, stepTitle: x.stepTitle } }), text))
|
|
1401
1454
|
wtUi.picks = []
|
|
1402
1455
|
return wtAskPut(sid, msg)
|
|
1403
1456
|
}
|
|
@@ -1405,10 +1458,18 @@ function wtAsk(sid, text) {
|
|
|
1405
1458
|
const st = data && data.status === 'ok' ? (data.steps || []).find((x) => x.id === wtUi.cur.get(wtKey(sid))) : null
|
|
1406
1459
|
const co = st && st.callouts[0]
|
|
1407
1460
|
const where = st ? (co ? co.file + ':' + co.line : st.files[0] + (st.snippets[0] ? ':' + ((/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(st.snippets[0].patch) || [])[1] || '') : '')).replace(/:$/, '') : ''
|
|
1408
|
-
const msg = (where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text
|
|
1461
|
+
const msg = lead((where ? 'About `' + where + '`' + (st ? ' (' + st.title + ')' : '') + ': ' : '') + text)
|
|
1409
1462
|
wtAskPut(sid, msg)
|
|
1410
1463
|
}
|
|
1411
1464
|
function wtAskPut(sid, msg) {
|
|
1465
|
+
// 0.9.17 a Codex session in the board's terminal: the question goes into Codex's own box there (pasted, not sent)
|
|
1466
|
+
const cs = wtSession(sid)
|
|
1467
|
+
if (cs && cs.codingAgent === 'codex' && cs.terminal && cs.terminal.kind === 'leftoff' && cs.terminal.termId) {
|
|
1468
|
+
const tid = cs.terminal.termId
|
|
1469
|
+
wtUi.ask = ''; wtClose()
|
|
1470
|
+
terminal.show(tid).then((ok) => ok && api('POST', '/v1/term/' + tid + '/in', { data: '\x1b[200~' + msg + '\x1b[201~' })).then((r) => toast(r ? 'In Codex’s box: press Enter there to send' : 'Its terminal isn’t running any more', 4000)).catch((e) => toast(e.message, 5000))
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1412
1473
|
const k = 'live:' + sid
|
|
1413
1474
|
const typed = ((state.drafts && state.drafts[k]) || '').trim()
|
|
1414
1475
|
state.drafts[k] = typed ? typed + '\n\n' + msg : msg
|
|
@@ -1764,6 +1825,9 @@ function repoRow(shown, f, tail = '') {
|
|
|
1764
1825
|
return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today', tail)
|
|
1765
1826
|
}
|
|
1766
1827
|
|
|
1828
|
+
// 2026-11-05 a repo outside a project: the same quiet ◌ count of decisions to review a project chip has
|
|
1829
|
+
// (the counts are per log; a chip's log is its sessions' decisionRepo)
|
|
1830
|
+
const repoReviewHtml = (c) => { const rv = projectsUi.repoReviews || {}; if (!Object.keys(rv).length) return ''; const s = (state.sessions || []).find((x) => x.decisionRepo && rv[x.decisionRepo] && repoKeyOf(x) === c.key); return s ? reviewCountHtml(rv[s.decisionRepo], { esc, cls: 'pchip-rc' }) : '' }
|
|
1767
1831
|
// The repo chip row, shared by the board and the timeline: All, the top 8,
|
|
1768
1832
|
// and More ▾ with a filter box. chips: repoChips() output.
|
|
1769
1833
|
function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
@@ -1774,7 +1838,7 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1774
1838
|
// with nothing in this view: a filter is never active out of sight.
|
|
1775
1839
|
chips = orderSelectedFirst(chips, selected)
|
|
1776
1840
|
const { shown: row, more } = splitChips(chips, selected, 8)
|
|
1777
|
-
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1841
|
+
const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + repoReviewHtml(c) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
|
|
1778
1842
|
const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
|
|
1779
1843
|
let menu = ''
|
|
1780
1844
|
if (more.length) {
|
|
@@ -1788,13 +1852,13 @@ function chipBar(chips, selected, allCount, emptyNote, tail = '') {
|
|
|
1788
1852
|
// ------------------------------------------------------------ projects (0.9.0)
|
|
1789
1853
|
// A project is a named group of repos (projects.json in the synced folder).
|
|
1790
1854
|
// Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
|
|
1791
|
-
const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
|
|
1855
|
+
const projectsUi = { list: [], repos: [], repoReviews: {}, mayEdit: false, at: 0 }
|
|
1792
1856
|
async function loadProjects({ fresh = false } = {}) {
|
|
1793
1857
|
if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
|
|
1794
|
-
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1858
|
+
try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.repoReviews = d.repoReviews || {}; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
|
|
1795
1859
|
return projectsUi
|
|
1796
1860
|
}
|
|
1797
|
-
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
|
|
1861
|
+
function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; if (d.repoReviews) projectsUi.repoReviews = d.repoReviews; projectsUi.at = Date.now() } }
|
|
1798
1862
|
// The small chip next to the repo: tap = show only this project.
|
|
1799
1863
|
function projChip(s) {
|
|
1800
1864
|
const p = s && s.repoProject
|
|
@@ -1857,7 +1921,7 @@ function projectRow(list, sel, action, counts, tail = '') {
|
|
|
1857
1921
|
const manage = sel && projectsUi.mayEdit && projectsUi.list.some((p) => p.id === sel) ? '<button type="button" class="btn icon-btn more proj-more" data-action="project-manage" data-project="' + esc(sel) + '" aria-label="Project actions" data-tip="Rename, edit repos, delete" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' : ''
|
|
1858
1922
|
// the ⋯ sits just past the scrolling track (whose edge fades only while chips run past it), never clipped
|
|
1859
1923
|
return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
|
|
1860
|
-
chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
|
|
1924
|
+
chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name) + reviewCountHtml(((projectsUi.list.find((p) => p.id === c.id) || {}).decisionsToReview) || 0, { esc, cls: 'pchip-rc' }), c.count, sel === c.id)).join('') + '</div>' + manage + tail + '</div>'
|
|
1861
1925
|
}
|
|
1862
1926
|
// 0.9.12: the board's own way in to a project with several repos: a "New
|
|
1863
1927
|
// project" button at the end of the project row (or the repo row before there
|
|
@@ -2534,9 +2598,31 @@ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few se
|
|
|
2534
2598
|
const SESSION_CACHE_MAX = 8
|
|
2535
2599
|
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
2536
2600
|
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
2601
|
+
// 2026-11-05 session titles: a one-line name that doesn't fit is cut at a word
|
|
2602
|
+
// (the longest whole-word cut that fits, with …), never mid-word by the
|
|
2603
|
+
// ellipsis; the whole name is in its tip.
|
|
2604
|
+
function fitTitleWords(el, text) {
|
|
2605
|
+
el.textContent = text
|
|
2606
|
+
if (text) el.setAttribute('data-tip', text); else el.removeAttribute('data-tip')
|
|
2607
|
+
if (!(el.clientWidth > 0) || el.scrollWidth <= el.clientWidth + 1) return
|
|
2608
|
+
let lo = 4, hi = text.length, best = ''
|
|
2609
|
+
while (lo <= hi) {
|
|
2610
|
+
const mid = (lo + hi) >> 1
|
|
2611
|
+
el.textContent = cutTitle(text, mid)
|
|
2612
|
+
if (el.scrollWidth <= el.clientWidth + 1) { best = el.textContent; lo = mid + 1 } else hi = mid - 1
|
|
2613
|
+
}
|
|
2614
|
+
el.textContent = best || cutTitle(text, lo)
|
|
2615
|
+
}
|
|
2616
|
+
addEventListener('resize', () => { const t = document.querySelector('#live.full .lf-title'); const full = t && t.getAttribute('data-tip'); if (full) fitTitleWords(t, full) }, { passive: true }) // a turned phone re-fits
|
|
2617
|
+
// 2026-11-05 session titles: where it runs, a small muted line under the name (the full folder in its tip)
|
|
2618
|
+
const wherePathOf = (s) => (s && ((s.repo && (s.repo.worktree || s.repo.root)) || s.cwd)) || ''
|
|
2619
|
+
function whereLineHtml(s) {
|
|
2620
|
+
const w = sessionWhere(s)
|
|
2621
|
+
return w ? '<div class="s-where faint" data-tip="' + esc(wherePathOf(s) || w) + '">' + titleHtml(w, esc) + '</div>' : ''
|
|
2622
|
+
}
|
|
2537
2623
|
function sessionShellHtml(s) {
|
|
2538
2624
|
const head = s
|
|
2539
|
-
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1
|
|
2625
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>' + whereLineHtml(s) + '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + '</div>'
|
|
2540
2626
|
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
2541
2627
|
return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
|
|
2542
2628
|
'<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
|
|
@@ -2577,6 +2663,7 @@ function freshSession(s) {
|
|
|
2577
2663
|
if (op && out.headless) out = { ...out, headless: pendingHeadless(out.headless, op) }
|
|
2578
2664
|
return out
|
|
2579
2665
|
}
|
|
2666
|
+
const switchPending = new Set() // 1.0 a Continue in terminal / headless on its way
|
|
2580
2667
|
const fetchFresh = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/headless', undefined, { timeoutMs: 8000 }).then((r) => (r && r.fresh) || null, () => null)
|
|
2581
2668
|
// Redraw the session page from what it has (no fetch): the bar and the switch at once.
|
|
2582
2669
|
function repaintSession(id) {
|
|
@@ -2741,7 +2828,7 @@ async function loadWid(id) {
|
|
|
2741
2828
|
paintWid(id)
|
|
2742
2829
|
await Promise.all([
|
|
2743
2830
|
api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
|
|
2744
|
-
api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
2831
|
+
api('GET', widPath(id, 'diff') + filesQ('?')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
2745
2832
|
])
|
|
2746
2833
|
ui.loading = false
|
|
2747
2834
|
// a file you had open shows its lines again
|
|
@@ -2753,7 +2840,7 @@ async function loadWidFile(id, p) {
|
|
|
2753
2840
|
const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
|
|
2754
2841
|
if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
|
|
2755
2842
|
ui.fileLoading[p] = true; paintWid(id)
|
|
2756
|
-
try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
|
|
2843
|
+
try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p) + filesQ('&')); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
|
|
2757
2844
|
delete ui.fileLoading[p]
|
|
2758
2845
|
paintWid(id)
|
|
2759
2846
|
}
|
|
@@ -2841,8 +2928,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2841
2928
|
// title is being made, its spinner sits in the cluster.
|
|
2842
2929
|
const fullTitle = sessionTitle(s)
|
|
2843
2930
|
const title = canRename
|
|
2844
|
-
? '<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
|
|
2845
|
-
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1
|
|
2931
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
2932
|
+
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + whereLineHtml(s) + '</div>'
|
|
2846
2933
|
const menu = fsMenuItems(s, mine)
|
|
2847
2934
|
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2848
2935
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
@@ -2852,7 +2939,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2852
2939
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2853
2940
|
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2854
2941
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2855
|
-
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(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>' +
|
|
2942
|
+
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + hooksQuietHtml(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div></div>' +
|
|
2856
2943
|
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2857
2944
|
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2858
2945
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
@@ -2873,6 +2960,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2873
2960
|
side += '<section class="scu-wrap" id="session-catchup">' + sessionCatchupHtml(scuOf(s.id), { esc, icon, sid: s.id }) + '</section>' // 0.9.9 Catch me up on this session, at the top
|
|
2874
2961
|
side += '<div class="sask-wrap" id="session-ask">' + sAskPanel(s).html + '</div>' // 0.9.11 Ask about this session
|
|
2875
2962
|
side += ownerOfflineNote(s)
|
|
2963
|
+
side += termBarHtml(s, { esc, may: state.mayAnswer !== false }) // 1.0 in Leftoff's terminal: Continue headless
|
|
2876
2964
|
side += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false, helpOpen: helpOpen.has(hmHelpId(s.id)) }) // 0.9.9: started from the board: Stop / Resume, Permissions
|
|
2877
2965
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
2878
2966
|
// Checkpoints: a timeline, latest first, the latest open
|
|
@@ -2885,7 +2973,11 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2885
2973
|
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2886
2974
|
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2887
2975
|
side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
|
|
2888
|
-
|
|
2976
|
+
side += decisionsGivenHtml(s, { esc, when: (iso) => clock(iso) + ' · ' + ago(iso) }) // 1b
|
|
2977
|
+
decisionsUi.panelSession = s // 1k: the session whose panel is drawn (Accept / Edit there redraw it)
|
|
2978
|
+
if (s.decisionRepo) noteSessionDecisions(s)
|
|
2979
|
+
side += '<div id="fsDecided">' + panelDecidedFold(s) + '</div>' // 2026-11-05 every decision the session made (its repo's log, else all its checkpoints'); 1k Accept / Reject / Edit right here
|
|
2980
|
+
if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths, { log: sessionLogBy(s) }) + '</div>' }, { esc })
|
|
2889
2981
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2890
2982
|
if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
2891
2983
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
@@ -3162,7 +3254,7 @@ function liveOpenHtml(s, v, sub) {
|
|
|
3162
3254
|
if (isFullLive(s.id)) {
|
|
3163
3255
|
const h = s.headless
|
|
3164
3256
|
const stop = h && h.state === 'running' && state.mayAnswer !== false ? '<button type="button" class="btn lf-stop" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' : ''
|
|
3165
|
-
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3257
|
+
return '<section class="live-box full" id="live" aria-label="Live link, full screen" data-id="' + esc(s.id) + '">' + fullBarHtml(s.id, { esc, icon, glyph: glyphFor(s, { cls: 'lf-sg' }), pills: statusPill(s) + liveBadge(s) + termPillHtml(s, { esc }) + mode, stop, retitle: retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id), cls: 'lf-retitle' }) }) +
|
|
3166
3258
|
'<nav class="lf-tabs" id="lfTabs" aria-label="Open sessions"' + (lfTabsUi.html ? '' : ' hidden') + '>' + lfTabsUi.html + '</nav>' // 0.9.14: the open tabs stay in full screen (paintLfTabs fills it)
|
|
3167
3259
|
}
|
|
3168
3260
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + mode + '<span class="faint">' + sub + '</span>' + fullButtonHtml(s.id, { esc, icon }) + '</div>'
|
|
@@ -3369,21 +3461,83 @@ function setFullClass(on) {
|
|
|
3369
3461
|
applyNav()
|
|
3370
3462
|
}
|
|
3371
3463
|
// The window as you see it: on a phone the keyboard takes the bottom of it,
|
|
3372
|
-
// and the composer stays just above the keyboard.
|
|
3464
|
+
// and the composer stays just above the keyboard. When the keyboard closes the
|
|
3465
|
+
// layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
|
|
3466
|
+
// its pan behind after a send, which put full screen ~300px down the screen).
|
|
3467
|
+
const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
|
|
3373
3468
|
function fitFullViewport() {
|
|
3374
3469
|
const vv = window.visualViewport
|
|
3375
|
-
const
|
|
3376
|
-
if (!
|
|
3470
|
+
const html = document.documentElement, st = html.style
|
|
3471
|
+
if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
|
|
3377
3472
|
// 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
|
|
3378
3473
|
const strip = document.getElementById('tabStrip')
|
|
3379
3474
|
const bar = strip && !strip.hidden && getComputedStyle(strip).display !== 'none' ? Math.round(strip.getBoundingClientRect().height) : 0
|
|
3380
3475
|
st.setProperty('--lf-bar', bar + 'px')
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
st.
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3476
|
+
const typing = isTypingTarget(document.activeElement)
|
|
3477
|
+
const fit = fullViewportFit({ innerHeight: window.innerHeight, vv, typing })
|
|
3478
|
+
if (!fit) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); html.classList.remove('lf-kb'); return }
|
|
3479
|
+
st.setProperty('--lf-h', fit.h + 'px')
|
|
3480
|
+
st.setProperty('--lf-top', fit.top + 'px')
|
|
3481
|
+
html.classList.toggle('lf-kb', fit.kb) // the keyboard is up: no home-indicator gap under the composer
|
|
3482
|
+
// the keyboard's gone: the page under full screen back where it was before the text box took focus
|
|
3483
|
+
if (!typing && lfKb.scroll != null && Math.abs(window.scrollY - lfKb.scroll) >= 1) window.scrollTo(window.scrollX, lfKb.scroll)
|
|
3484
|
+
if (typing) keepTypingInView()
|
|
3485
|
+
}
|
|
3486
|
+
// 2026-11-04 (switching to another keyboard mid-typing hid the composer, and switching back didn't
|
|
3487
|
+
// bring it back): while you type, check where the composer really is against what you can see, from
|
|
3488
|
+
// this moment's numbers only, and if it isn't in view move full screen by the difference.
|
|
3489
|
+
// (Since 0.9.16 a session always opens in full screen, so that's the Live composer's only home.)
|
|
3490
|
+
function keepTypingInView() {
|
|
3491
|
+
const vv = window.visualViewport, el = document.activeElement, html = document.documentElement
|
|
3492
|
+
if (!vv || !html.classList.contains('live-full') || !isTypingTarget(el) || !el.closest) return
|
|
3493
|
+
const box = el.closest('.live-box.full')
|
|
3494
|
+
if (!box) return
|
|
3495
|
+
const d = outOfView((el.closest('.composer') || el).getBoundingClientRect(), vv)
|
|
3496
|
+
if (d) html.style.setProperty('--lf-top', Math.round((parseFloat(html.style.getPropertyValue('--lf-top')) || 0) - d) + 'px')
|
|
3497
|
+
}
|
|
3498
|
+
// Every report while you type (a keyboard opening, swapping, changing height, or iOS scrolling the
|
|
3499
|
+
// page under it) fits full screen then and looks again a few beats after: a swap's last change can
|
|
3500
|
+
// come with no report of its own, and a passing bogus one is put right by the next look.
|
|
3501
|
+
function onViewportChange(ev) {
|
|
3502
|
+
fitFullViewport()
|
|
3503
|
+
if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) settleWhileTyping(1)
|
|
3504
|
+
}
|
|
3505
|
+
function settleWhileTyping(from = 0) {
|
|
3506
|
+
clearTimeout(lfKb.settle)
|
|
3507
|
+
if (from >= SETTLE_WHILE_TYPING_MS.length) return
|
|
3508
|
+
let i = from
|
|
3509
|
+
const step = () => {
|
|
3510
|
+
if (!isTypingTarget(document.activeElement)) return
|
|
3511
|
+
fitFullViewport()
|
|
3512
|
+
if (++i < SETTLE_WHILE_TYPING_MS.length) lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - SETTLE_WHILE_TYPING_MS[i - 1])
|
|
3513
|
+
}
|
|
3514
|
+
lfKb.settle = setTimeout(step, SETTLE_WHILE_TYPING_MS[i] - (i ? SETTLE_WHILE_TYPING_MS[i - 1] : 0))
|
|
3515
|
+
}
|
|
3516
|
+
if (window.visualViewport) for (const ev of ['resize', 'scroll']) window.visualViewport.addEventListener(ev, onViewportChange)
|
|
3517
|
+
window.addEventListener('scroll', (ev) => { if (isTypingTarget(document.activeElement) && document.documentElement.classList.contains('live-full')) onViewportChange(ev) }, { passive: true })
|
|
3518
|
+
for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, onViewportChange)
|
|
3519
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') fitFullViewport() })
|
|
3520
|
+
document.addEventListener('focusin', (e) => {
|
|
3521
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3522
|
+
if (lfKb.scroll == null) lfKb.scroll = window.scrollY
|
|
3523
|
+
// 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
|
|
3524
|
+
// visual viewport without a resize event of its own: measure again as it opens, as on the way down
|
|
3525
|
+
settleWhileTyping(0)
|
|
3526
|
+
})
|
|
3527
|
+
// A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
|
|
3528
|
+
// goes down, whatever iOS reports or doesn't on the way.
|
|
3529
|
+
document.addEventListener('focusout', (e) => {
|
|
3530
|
+
if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
|
|
3531
|
+
clearTimeout(lfKb.settle)
|
|
3532
|
+
let i = 0
|
|
3533
|
+
const step = () => {
|
|
3534
|
+
fitFullViewport()
|
|
3535
|
+
if (isTypingTarget(document.activeElement)) return // another text box took focus: the keyboard stays
|
|
3536
|
+
if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
|
|
3537
|
+
else lfKb.scroll = null
|
|
3538
|
+
}
|
|
3539
|
+
lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
|
|
3540
|
+
})
|
|
3387
3541
|
function fullAfterRender(sid) {
|
|
3388
3542
|
const box = document.querySelector('.live-box.full')
|
|
3389
3543
|
setFullClass(!!box)
|
|
@@ -3391,7 +3545,9 @@ function fullAfterRender(sid) {
|
|
|
3391
3545
|
if (box) {
|
|
3392
3546
|
const s = state.sessions.find((x) => x.id === sid) || ((sessionCache.get(sid) || {}).s)
|
|
3393
3547
|
const t = box.querySelector('.lf-title')
|
|
3394
|
-
if (t) t
|
|
3548
|
+
if (t) fitTitleWords(t, sessionTitle(s)) // the session's name, as text, cut at a word to fit the bar
|
|
3549
|
+
const w = box.querySelector('.lf-where')
|
|
3550
|
+
if (w) { w.textContent = sessionWhere(s); if (w.textContent) w.setAttribute('data-tip', wherePathOf(s) || w.textContent); else w.removeAttribute('data-tip') } // 2026-11-05: folder@branch, muted, its own line
|
|
3395
3551
|
paintLfTabs()
|
|
3396
3552
|
const ol = box.querySelector('.live-log')
|
|
3397
3553
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
@@ -3628,11 +3784,40 @@ function composerFor(el) {
|
|
|
3628
3784
|
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
3629
3785
|
return box && !box.classList.contains('off') ? box : null
|
|
3630
3786
|
}
|
|
3787
|
+
// The picker the paperclip opens lives outside the page (0.9.18). A phone's
|
|
3788
|
+
// photo picker covers the board for seconds; coming back is a wake-up (focus
|
|
3789
|
+
// after more than AWAY_MS away) that redraws the page, and iOS hands the
|
|
3790
|
+
// picked photo over only after that, to the <input> it opened from. A picker
|
|
3791
|
+
// inside the composer was gone by then (the redraw made a new one), so its
|
|
3792
|
+
// change event went nowhere and the photo never showed. This one is never
|
|
3793
|
+
// redrawn; the composer's own hidden input stays for drops and tests.
|
|
3794
|
+
let attPicker = null
|
|
3795
|
+
function attachPicker() {
|
|
3796
|
+
if (attPicker && attPicker.isConnected) return attPicker
|
|
3797
|
+
attPicker = document.createElement('input')
|
|
3798
|
+
attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
|
|
3799
|
+
attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
|
|
3800
|
+
attPicker.setAttribute('aria-hidden', 'true')
|
|
3801
|
+
document.body.appendChild(attPicker)
|
|
3802
|
+
return attPicker
|
|
3803
|
+
}
|
|
3804
|
+
function openPicker(sid) {
|
|
3805
|
+
if (!sid) return
|
|
3806
|
+
const pk = attachPicker()
|
|
3807
|
+
pk.dataset.sid = sid
|
|
3808
|
+
pk.value = ''
|
|
3809
|
+
pk.click() // still inside the tap: iOS opens a picker only from a user gesture
|
|
3810
|
+
}
|
|
3811
|
+
function takePicked(inp) {
|
|
3812
|
+
const files = Array.from(inp.files || [])
|
|
3813
|
+
if (!files.length) return
|
|
3814
|
+
inp.value = '' // the same file can be picked again
|
|
3815
|
+
try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
|
|
3816
|
+
}
|
|
3631
3817
|
document.addEventListener('change', (ev) => {
|
|
3632
3818
|
const inp = ev.target
|
|
3633
3819
|
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
3634
|
-
|
|
3635
|
-
inp.value = '' // the same file can be picked again
|
|
3820
|
+
takePicked(inp)
|
|
3636
3821
|
})
|
|
3637
3822
|
document.addEventListener('paste', (ev) => {
|
|
3638
3823
|
const t = ev.target
|
|
@@ -4375,10 +4560,11 @@ async function aroundWire(id) {
|
|
|
4375
4560
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4376
4561
|
}
|
|
4377
4562
|
}
|
|
4378
|
-
|
|
4563
|
+
// decided: { log } shows each checkpoint's own Decided lines among its extras, read-only (2026-11-05), with the log's word when loaded
|
|
4564
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null, decided = null) {
|
|
4379
4565
|
return list.map((cp) =>
|
|
4380
4566
|
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
|
|
4381
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
|
|
4567
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths, decided }) +
|
|
4382
4568
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4383
4569
|
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4384
4570
|
(wtSid && cp === newestCp ? '<button type="button" class="linkish wt-since" data-action="wt-open" data-id="' + esc(wtSid) + '" data-scope="checkpoint">' + icon('route') + 'Walk through since here</button>' : '') + // 0.9.17
|
|
@@ -4388,6 +4574,268 @@ function cpHeadLine(cp) {
|
|
|
4388
4574
|
return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
|
|
4389
4575
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4390
4576
|
}
|
|
4577
|
+
// 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
|
|
4578
|
+
const decisionsUi = { repoLog: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {}, panelSession: null }
|
|
4579
|
+
const panelSess = () => (state.route && state.route.name === 's' && decisionsUi.panelSession && decisionsUi.panelSession.id === state.route.arg ? decisionsUi.panelSession : null)
|
|
4580
|
+
// 2026-11-05 the session's rows in its repo's log (null until loaded, or with no repo), and the same keyed by text
|
|
4581
|
+
const sessionLogRows = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? v.filter((d) => d.sessionId === s.id) : null }
|
|
4582
|
+
const sessionLogBy = (s) => { const v = s && s.decisionRepo && decisionsUi.repoLog[s.decisionRepo]; return v ? logBySession(v, s.id) : null }
|
|
4583
|
+
// The Decided fold: every decision the session made, shown whenever there's at least one
|
|
4584
|
+
function panelDecidedFold(s) {
|
|
4585
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4586
|
+
if (!list.length) return ''
|
|
4587
|
+
return foldHtml({ key: 'cp-decided', title: 'Decided', count: sessionDecisionsCount(list), open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent }) }, { esc })
|
|
4588
|
+
}
|
|
4589
|
+
// The session panel reads its repo's log (once per repo per visit, quietly); the old rows stay on screen until the new ones land
|
|
4590
|
+
function noteSessionDecisions(s) {
|
|
4591
|
+
const repo = s && s.decisionRepo
|
|
4592
|
+
if (!repo || decisionsUi.loadedRepo[repo]) return
|
|
4593
|
+
decisionsUi.loadedRepo[repo] = true
|
|
4594
|
+
api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
|
|
4595
|
+
decisionsUi.repoLog[repo] = Array.isArray(v && v.decisions) ? v.decisions : []
|
|
4596
|
+
const ps = panelSess()
|
|
4597
|
+
if (ps && ps.decisionRepo === repo) paintPanelDecided(ps)
|
|
4598
|
+
}).catch(() => { decisionsUi.loadedRepo[repo] = false })
|
|
4599
|
+
}
|
|
4600
|
+
// ---- testplan Part 3: #/testplan/new?scope=…&prs=… (before a run) and #/testplan/<id>?machine=… (a plan)
|
|
4601
|
+
const tpUi = { id: null, machine: null, plan: null, timer: null, noting: null, proposing: null, nu: null }
|
|
4602
|
+
const tpTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4603
|
+
async function viewTestplan(arg, params) {
|
|
4604
|
+
clearTimeout(tpUi.timer)
|
|
4605
|
+
if (arg === 'new') {
|
|
4606
|
+
const q = { scope: params.get('scope') || '', since: params.get('since') || '', prs: params.get('prs') || '' }
|
|
4607
|
+
const key = JSON.stringify(q)
|
|
4608
|
+
if (!tpUi.nu || tpUi.nu.key !== key) tpUi.nu = { key, q, preview: null, recent: null, minutes: 30, starting: false, error: null }
|
|
4609
|
+
paintTestplanNew()
|
|
4610
|
+
const qs = 'scope=' + encodeURIComponent(q.scope) + (q.since ? '&since=' + encodeURIComponent(q.since) : '') + (q.prs ? '&prs=' + q.prs.split(',').map((u) => encodeURIComponent(decodeURIComponent(u))).join(',') : '')
|
|
4611
|
+
try { tpUi.nu.preview = await api('GET', '/v1/testplans/preview?' + qs, undefined, { timeoutMs: 30000 }) } catch (e) { tpUi.nu.error = e.message || String(e) }
|
|
4612
|
+
try { tpUi.nu.recent = await api('GET', '/v1/testplans' + (q.scope ? '?scope=' + encodeURIComponent(q.scope) : ''), undefined, { timeoutMs: 8000 }) } catch {}
|
|
4613
|
+
paintTestplanNew()
|
|
4614
|
+
const w = tpUi.nu.preview && tpUi.nu.preview.walking
|
|
4615
|
+
if (w && w.running) tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === 'new') viewTestplan('new', params) }, 2500)
|
|
4616
|
+
return
|
|
4617
|
+
}
|
|
4618
|
+
const machine = params.get('machine') || null
|
|
4619
|
+
if (tpUi.id !== arg || tpUi.machine !== machine) { tpUi.id = arg; tpUi.machine = machine; tpUi.plan = null; tpUi.noting = null; tpUi.proposing = null; paintTestplan() }
|
|
4620
|
+
tpUi.plan = await api('GET', '/v1/testplans/' + encodeURIComponent(arg) + (machine ? '?machine=' + encodeURIComponent(machine) : ''), undefined, { timeoutMs: 10000 })
|
|
4621
|
+
paintTestplan()
|
|
4622
|
+
if (tpUi.plan && tpUi.plan.status === 'running') tpUi.timer = setTimeout(() => { if (state.route && state.route.name === 'testplan' && state.route.arg === arg) viewTestplan(arg, params) }, 1500)
|
|
4623
|
+
}
|
|
4624
|
+
function paintTestplanNew() {
|
|
4625
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg !== 'new' || !tpUi.nu) return
|
|
4626
|
+
const n = tpUi.nu
|
|
4627
|
+
$app.innerHTML = testplanNewHtml(n.preview, { minutes: n.minutes, trusted: tpTrusted(), starting: n.starting, recent: n.recent, here: state.health && state.health.machine }, { esc }) + (n.error ? '<div class="banner warn" role="alert"><span>' + esc(n.error) + '</span></div>' : '')
|
|
4628
|
+
}
|
|
4629
|
+
function paintTestplan() {
|
|
4630
|
+
if (!state.route || state.route.name !== 'testplan' || state.route.arg === 'new') return
|
|
4631
|
+
$app.innerHTML = testplanPageHtml(tpUi.plan, { trusted: tpTrusted(), noting: tpUi.noting, proposing: tpUi.proposing, here: state.health && state.health.machine }, { esc })
|
|
4632
|
+
}
|
|
4633
|
+
async function tpCheck(id, check, body) {
|
|
4634
|
+
try { tpUi.plan = await api('POST', '/v1/testplans/' + encodeURIComponent(id) + '/check' + (tpUi.machine ? '?machine=' + encodeURIComponent(tpUi.machine) : ''), { check, ...body }); paintTestplan() } catch (e) { toast(e.message || String(e)) }
|
|
4635
|
+
}
|
|
4636
|
+
document.addEventListener('click', async (ev) => {
|
|
4637
|
+
const b = ev.target.closest && ev.target.closest('[data-action^="tp-"]')
|
|
4638
|
+
if (!b || b.tagName === 'FORM' || b.disabled) return
|
|
4639
|
+
const a = b.dataset.action
|
|
4640
|
+
if (a === 'tp-minutes') { if (tpUi.nu) { tpUi.nu.minutes = Number(b.dataset.m) || 30; paintTestplanNew() } return }
|
|
4641
|
+
if (a === 'tp-run' || a === 'tp-walk') {
|
|
4642
|
+
const n = tpUi.nu
|
|
4643
|
+
if (!n) return
|
|
4644
|
+
const body = { scope: n.q.scope, since: n.q.since || null, prs: n.q.prs ? n.q.prs.split(',').map((u) => decodeURIComponent(u)) : null }
|
|
4645
|
+
if (a === 'tp-walk') { try { await api('POST', '/v1/testplans/walk', body); toast('Walking through them one at a time'); viewTestplan('new', state.route.params) } catch (e) { toast(e.message || String(e)) } return }
|
|
4646
|
+
n.starting = true; paintTestplanNew()
|
|
4647
|
+
try { const r = await api('POST', '/v1/testplans', { ...body, minutes: n.minutes }, { timeoutMs: 90000 }); location.hash = testplanHref(r.id) } catch (e) { n.starting = false; n.error = e.message || String(e); paintTestplanNew() }
|
|
4648
|
+
return
|
|
4649
|
+
}
|
|
4650
|
+
const id = b.dataset.id, check = b.dataset.check
|
|
4651
|
+
if (a === 'tp-tick') { const cur = tpUi.plan && tpUi.plan.ticks && tpUi.plan.ticks[check]; const want = b.dataset.state || null; return tpCheck(id, check, { state: cur && cur.state === want ? null : want }) }
|
|
4652
|
+
if (a === 'tp-propose') { tpUi.proposing = check; paintTestplan(); const i = document.querySelector('.tp-propf .dec-in'); if (i) i.focus({ preventScroll: true }); return }
|
|
4653
|
+
if (a === 'tp-propose-cancel') { tpUi.proposing = null; return paintTestplan() }
|
|
4654
|
+
if (a === 'tp-copy') { const md = tpUi.plan && planMarkdown(tpUi.plan); if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
|
|
4655
|
+
if (a === 'tp-download') {
|
|
4656
|
+
if (!tpUi.plan) return
|
|
4657
|
+
const url = URL.createObjectURL(new Blob([planHtmlDoc(tpUi.plan)], { type: 'text/html' }))
|
|
4658
|
+
const x = document.createElement('a'); x.href = url; x.download = exportName('Test plan ' + ((tpUi.plan.scope && tpUi.plan.scope.label) || '')); document.body.appendChild(x); x.click(); x.remove()
|
|
4659
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
4660
|
+
return
|
|
4661
|
+
}
|
|
4662
|
+
if (a === 'tp-delete') { if (!confirm('Delete this test plan?')) return; try { await api('DELETE', '/v1/testplans/' + encodeURIComponent(id)); location.hash = '#/prs' } catch (e) { toast(e.message || String(e)) } }
|
|
4663
|
+
})
|
|
4664
|
+
document.addEventListener('submit', async (ev) => {
|
|
4665
|
+
const f = ev.target.closest && ev.target.closest('form[data-action^="tp-"]')
|
|
4666
|
+
if (!f) return
|
|
4667
|
+
ev.preventDefault()
|
|
4668
|
+
if (f.dataset.action === 'tp-note') return tpCheck(f.dataset.id, f.dataset.check, { note: f.querySelector('[name="note"]').value })
|
|
4669
|
+
if (f.dataset.action === 'tp-propose-save') {
|
|
4670
|
+
try {
|
|
4671
|
+
const r = await api('POST', '/v1/testplans/' + encodeURIComponent(f.dataset.id) + '/propose', { check: f.dataset.check, text: f.querySelector('[name="text"]').value })
|
|
4672
|
+
tpUi.plan = r.plan; tpUi.proposing = null; paintTestplan(); toast('Proposed ' + (r.decision ? r.decision.id : '') + ': review it in the decision log')
|
|
4673
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4674
|
+
}
|
|
4675
|
+
})
|
|
4676
|
+
// 1k: the panel's Decided fold, drawn again in place (editing one, after the log loaded, after Accept): its body and
|
|
4677
|
+
// count only, so the fold keeps its element, its open state and the scroll around it; added when it wasn't there
|
|
4678
|
+
function paintPanelDecided(s) {
|
|
4679
|
+
const slot = document.getElementById('fsDecided')
|
|
4680
|
+
if (!slot || !s) return
|
|
4681
|
+
const list = sessionDecisions(s.checkpoints, sessionLogRows(s))
|
|
4682
|
+
const fold = slot.querySelector('[data-fold="cp-decided"]')
|
|
4683
|
+
if (!list.length) { if (fold) slot.innerHTML = ''; return }
|
|
4684
|
+
if (!fold) { slot.innerHTML = panelDecidedFold(s); return }
|
|
4685
|
+
const n = fold.querySelector('.fs-fold-n'), cnt = ' · ' + sessionDecisionsCount(list)
|
|
4686
|
+
if (n && n.textContent !== cnt) n.textContent = cnt
|
|
4687
|
+
const box = fold.querySelector('.fs-fold-b')
|
|
4688
|
+
const html = decidedListHtml(list, { esc, repoId: s.decisionRepo || null, trusted: decisionsTrusted(), editing: decisionsUi.editing, may: !s.agent })
|
|
4689
|
+
if (box && box.innerHTML !== html) box.innerHTML = html
|
|
4690
|
+
// the Timeline's older checkpoints read the same log
|
|
4691
|
+
const by = sessionLogBy(s)
|
|
4692
|
+
if (by) for (const sec of document.querySelectorAll('#fsCps .cpx-decided[data-cp]')) {
|
|
4693
|
+
const cp = (s.checkpoints || []).find((c) => c.id === sec.dataset.cp)
|
|
4694
|
+
if (cp) { const h = decidedSecHtml(cp, by); if (sec.outerHTML !== h) sec.outerHTML = h }
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
// 1k: after Accept / Reject / Edit, the panel's Decided list reads the log again
|
|
4698
|
+
function refreshPanelDecisions(s) {
|
|
4699
|
+
if (!s || !s.decisionRepo) return
|
|
4700
|
+
decisionsUi.loadedRepo[s.decisionRepo] = false
|
|
4701
|
+
noteSessionDecisions(s)
|
|
4702
|
+
}
|
|
4703
|
+
// ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
|
|
4704
|
+
async function viewDecisions(arg) {
|
|
4705
|
+
const raw = decodeURIComponent(arg || '')
|
|
4706
|
+
if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
|
|
4707
|
+
decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
|
|
4708
|
+
const want = state.route && state.route.params && state.route.params.get('d')
|
|
4709
|
+
if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
|
|
4710
|
+
paintDecisions()
|
|
4711
|
+
if (want) { const el = document.getElementById('dec-' + want); if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600) } }
|
|
4712
|
+
}
|
|
4713
|
+
function paintDecisions() {
|
|
4714
|
+
if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
|
|
4715
|
+
$app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: decisionsTrusted() }, { esc })
|
|
4716
|
+
if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
|
|
4717
|
+
}
|
|
4718
|
+
async function decisionAction(repo, ref, action, body = {}) {
|
|
4719
|
+
try {
|
|
4720
|
+
const r = await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
|
|
4721
|
+
decisionsUi.loadedRepo = {}
|
|
4722
|
+
// a walkthrough citing it shows its new word (Proposed goes once accepted)
|
|
4723
|
+
const dd = r && r.decision
|
|
4724
|
+
if (dd) for (const w of wtUi.data.values()) for (const x of (w && w.decisions) || []) if (x.id === dd.id) { if (dd.status === 'proposed') x.status = 'proposed'; else delete x.status; wtUi.sig = '' }
|
|
4725
|
+
if (wtUi.sid) wtFill(wtSession(wtUi.sid))
|
|
4726
|
+
const dv = document.querySelector('.dv-isdec:not([hidden])')
|
|
4727
|
+
if (dv && decViewerCtx) openDecisionViewer(decViewerCtx.repo, decViewerCtx.id, decViewerCtx.from, decViewerCtx.ctx)
|
|
4728
|
+
if (panelSess()) refreshPanelDecisions(panelSess()) // 1k
|
|
4729
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4730
|
+
else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
|
|
4731
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4732
|
+
}
|
|
4733
|
+
document.addEventListener('click', async (ev) => {
|
|
4734
|
+
const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
|
|
4735
|
+
if (!btn || btn.tagName === 'FORM') return
|
|
4736
|
+
const a = btn.dataset.action
|
|
4737
|
+
if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
|
|
4738
|
+
if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
|
|
4739
|
+
if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; if (panelSess()) { paintPanelDecided(panelSess()); const i = document.querySelector('#decp-' + CSS.escape(btn.dataset.id) + ' .dec-in'); if (i) i.focus({ preventScroll: true }); return } return paintDecisions() }
|
|
4740
|
+
if (a === 'dec-cancel') { decisionsUi.editing = null; if (panelSess()) return paintPanelDecided(panelSess()); return paintDecisions() }
|
|
4741
|
+
if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
|
|
4742
|
+
if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
|
|
4743
|
+
if (a === 'dec-show') {
|
|
4744
|
+
const el = document.getElementById('dec-' + btn.dataset.id)
|
|
4745
|
+
if (el) { el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600); return }
|
|
4746
|
+
if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
|
|
4747
|
+
return
|
|
4748
|
+
}
|
|
4749
|
+
if (a === 'dec-store') {
|
|
4750
|
+
const on = btn.dataset.on === 'true'
|
|
4751
|
+
try {
|
|
4752
|
+
await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
|
|
4753
|
+
toast(on ? 'Accepted decisions now go into each repo’s docs/DECISIONS.md' : 'The decision log stays in Leftoff; repo files are left as they are')
|
|
4754
|
+
if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
|
|
4755
|
+
else loadProjectDecisions(qaUi.token, true)
|
|
4756
|
+
} catch (e) { toast(e.message || String(e)) }
|
|
4757
|
+
}
|
|
4758
|
+
})
|
|
4759
|
+
// A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
|
|
4760
|
+
document.addEventListener('click', (ev) => {
|
|
4761
|
+
const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
|
|
4762
|
+
if (!b) return
|
|
4763
|
+
ev.preventDefault()
|
|
4764
|
+
const s = wtSession(wtUi.sid)
|
|
4765
|
+
openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b, { departs: b.dataset.departs === '1', sessionId: wtUi.sid || null })
|
|
4766
|
+
})
|
|
4767
|
+
// 1h: the board's gate for changing the decision log (POST /v1/decisions/action is trustedOnly)
|
|
4768
|
+
const decisionsTrusted = () => state.mayAnswer !== false && (state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed))
|
|
4769
|
+
let decViewerCtx = null // 1f/1g: the viewer's last open, to redraw after Accept / Reject / a link
|
|
4770
|
+
async function openDecisionViewer(repo, id, from, ctx = {}) {
|
|
4771
|
+
if (!repo || !id) return
|
|
4772
|
+
decViewerCtx = { repo, id, from, ctx }
|
|
4773
|
+
let v
|
|
4774
|
+
try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
|
|
4775
|
+
const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
|
|
4776
|
+
const d = byId.get(id)
|
|
4777
|
+
const el = docViewerEl()
|
|
4778
|
+
el.querySelector('.dv-title').textContent = 'Decision ' + id
|
|
4779
|
+
el.querySelector('.dv-dl').hidden = true
|
|
4780
|
+
el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
|
|
4781
|
+
el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
|
|
4782
|
+
// the chain: what it replaced (oldest first), the decision, what replaced it
|
|
4783
|
+
const chain = []
|
|
4784
|
+
if (d) {
|
|
4785
|
+
const back = []; let cur = d, guard = 0
|
|
4786
|
+
while (cur && cur.supersedes && cur.supersedes.length && guard++ < 20) { const o = byId.get(cur.supersedes[0]); if (!o || back.includes(o)) break; back.unshift(o); cur = o }
|
|
4787
|
+
chain.push(...back, d)
|
|
4788
|
+
cur = d; guard = 0
|
|
4789
|
+
while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
|
|
4790
|
+
}
|
|
4791
|
+
// 1f: a proposed one (a walkthrough cites this session's own) can be accepted or rejected right here, same API and gate.
|
|
4792
|
+
// 1g: from a step that departs from it, this session's own decisions offer the supersede link (once accepted).
|
|
4793
|
+
const trusted = decisionsTrusted()
|
|
4794
|
+
const item = (x) => decisionItemHtml(x, { esc, byId, may: x === d && x.status === 'proposed', review: x === d && x.status === 'proposed', edit: false, trusted })
|
|
4795
|
+
let offer = ''
|
|
4796
|
+
if (d && ctx.departs && ctx.sessionId) {
|
|
4797
|
+
const own = list.filter((x) => x.sessionId === ctx.sessionId && x.id !== d.id && (x.status === 'proposed' || x.status === 'accepted') && !(x.supersedes || []).includes(d.id))
|
|
4798
|
+
if (own.length) offer = '<section class="dec-offer"><h3>This step departs from ' + esc(d.id) + '</h3><p class="faint">If one of this session’s decisions changes it, link them so ' + esc(d.id) + ' reads as superseded.</p><ul class="dec-tl">' +
|
|
4799
|
+
own.map((x) => decisionItemHtml(x, { esc, byId, may: x.status === 'proposed', review: x.status === 'proposed', edit: false, trusted, replaces: x.status === 'accepted' ? d.id : null })).join('') + '</ul></section>'
|
|
4800
|
+
}
|
|
4801
|
+
el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map(item).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') + offer +
|
|
4802
|
+
'<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
|
|
4803
|
+
el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
|
|
4804
|
+
refUi.from = from || null
|
|
4805
|
+
el.hidden = false
|
|
4806
|
+
document.documentElement.classList.add('dv-open')
|
|
4807
|
+
const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
|
|
4808
|
+
}
|
|
4809
|
+
document.addEventListener('submit', (ev) => {
|
|
4810
|
+
const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
|
|
4811
|
+
if (!f) return
|
|
4812
|
+
ev.preventDefault()
|
|
4813
|
+
const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
|
|
4814
|
+
decisionsUi.editing = null
|
|
4815
|
+
decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
|
|
4816
|
+
})
|
|
4817
|
+
// The project page's Decisions section
|
|
4818
|
+
// (2026-11-05) or a single repo's page: that repo's log (the page names its id)
|
|
4819
|
+
const pageDecisionScope = (token) => { const p = qaUi.page; return p && p.scope && p.scope.kind !== 'project' ? (p.decisionRepo || null) : 'project:' + token }
|
|
4820
|
+
async function loadProjectDecisions(token, force = false) {
|
|
4821
|
+
if (!token) return
|
|
4822
|
+
const scopeArg = pageDecisionScope(token)
|
|
4823
|
+
if (!scopeArg) return
|
|
4824
|
+
try {
|
|
4825
|
+
const v = await api('GET', '/v1/decisions?' + decisionScope(scopeArg).api, undefined, { timeoutMs: 8000 })
|
|
4826
|
+
decisionsUi.project = { token, v, scopeArg }
|
|
4827
|
+
paintProjectDecisions()
|
|
4828
|
+
} catch { decisionsUi.project = null }
|
|
4829
|
+
}
|
|
4830
|
+
function paintProjectDecisions() {
|
|
4831
|
+
const box = document.getElementById('projDecisionsBox')
|
|
4832
|
+
const pd = decisionsUi.project
|
|
4833
|
+
if (!box || !pd || pd.token !== qaUi.token) return
|
|
4834
|
+
const scopeArg = pd.scopeArg || 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token)
|
|
4835
|
+
box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, trusted: decisionsTrusted(), scopeArg })
|
|
4836
|
+
const slot = document.getElementById('projDecReview') // 1k: the header's quiet count
|
|
4837
|
+
if (slot) slot.innerHTML = reviewCountHtml((pd.v.decisions || []).filter((d) => d.status === 'proposed').length, { esc, href: decisionsHref(scopeArg), cls: 'proj-review' })
|
|
4838
|
+
}
|
|
4391
4839
|
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4392
4840
|
// your note and the files behind More (open or closed as you last left it on this device).
|
|
4393
4841
|
function cpLatestHtml(cp, wtSid, paths) {
|
|
@@ -4415,10 +4863,10 @@ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
|
4415
4863
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
4416
4864
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
4417
4865
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
4418
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {} }
|
|
4866
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {}, done: {}, picks: new Set() } // testplan Part 3: picks, merged PRs for a test plan
|
|
4419
4867
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
4420
4868
|
// tap repaints from what we have and reads again only when that is old.
|
|
4421
|
-
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
|
|
4869
|
+
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list, picks: prsUi.picks } }
|
|
4422
4870
|
function paintPrs(d) {
|
|
4423
4871
|
const q = document.getElementById('prs-q')
|
|
4424
4872
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -4567,7 +5015,8 @@ function briefAction(id, sessions, viewer) {
|
|
|
4567
5015
|
|
|
4568
5016
|
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
4569
5017
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
4570
|
-
|
|
5018
|
+
// testplan Part 2: the Decisions part links into the log: [D-7f3a](#/decisions/…) only, nothing else becomes a link
|
|
5019
|
+
const inline = (t) => esc(t).replace(/\[([^\]]{1,80})\]\((#\/decisions\/[^)\s"<>]{1,200})\)/g, '<a class="dg-dec" href="$2">$1</a>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
4571
5020
|
const out = []
|
|
4572
5021
|
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
4573
5022
|
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
@@ -5110,6 +5559,7 @@ function walkValueOf(w) {
|
|
|
5110
5559
|
const s = w.saved || null
|
|
5111
5560
|
const choice = s ? s.choice : w.choice
|
|
5112
5561
|
if (choice === 'agent') return 'agent'
|
|
5562
|
+
if (choice === 'codex') return 'codex' // 0.9.17
|
|
5113
5563
|
if (choice === 'claude') return 'claude:' + ((s && s.model) || 'sonnet')
|
|
5114
5564
|
if (choice === 'ollama') return 'ollama:' + ((s && s.model) || w.model || '')
|
|
5115
5565
|
return 'summarizer'
|
|
@@ -5151,7 +5601,7 @@ function paintWalk() {
|
|
|
5151
5601
|
let h = '', h0 = ''
|
|
5152
5602
|
// first: this session's own agent (recommended where Claude Code is here)
|
|
5153
5603
|
const ag = opt('agent')
|
|
5154
|
-
const fbLine = d.fallback ? 'When a session can’t be forked
|
|
5604
|
+
const fbLine = d.fallback ? 'When a session can’t be forked, ' + d.fallback.label + ' writes it' + (d.codexFound ? '; Codex sessions use Codex, and Codex stands in when Claude is unavailable.' : '.') : ''
|
|
5155
5605
|
h0 = card({ value: 'agent', on: !typed && kind === 'agent', name: ag.name, note: ag.line, suggested: d.claudeFound && d.suggested === 'agent', disabled: !d.claudeFound,
|
|
5156
5606
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? (kind === 'agent' && !typed ? fbLine : '') : 'Needs Claude Code on this machine.' })
|
|
5157
5607
|
const cl = opt('claude')
|
|
@@ -5166,6 +5616,10 @@ function paintWalk() {
|
|
|
5166
5616
|
h += h0
|
|
5167
5617
|
h += card({ value: 'claude:' + cm, on: claudeOn, name: cl.name, note: cl.line, suggested: d.claudeFound && d.suggested === 'claude', disabled: !d.claudeFound,
|
|
5168
5618
|
side: d.claudeFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.claudeFound ? '' : 'Install Claude Code on this machine to use it.', extra: seg })
|
|
5619
|
+
// 0.9.17 Codex: read-only codex exec in the session's folder; Codex sessions use it by default, and Claude's stand-in when it's unavailable
|
|
5620
|
+
const cx = opt('codex')
|
|
5621
|
+
h += card({ value: 'codex', on: !typed && kind === 'codex', name: cx.name, note: cx.line, disabled: !d.codexFound,
|
|
5622
|
+
side: d.codexFound ? '' : '<span class="badge wk-nf">○ not found here</span>', warn: d.codexFound ? '' : 'Install Codex on this machine to use it.' })
|
|
5169
5623
|
const smo = opt('summarizer')
|
|
5170
5624
|
h += card({ value: 'summarizer', on: !typed && kind === 'summarizer', name: smo.name, note: smo.line, warn: d.summarizerSmall ? 'Small for this job: expect thinner walkthroughs.' : '' })
|
|
5171
5625
|
const ol = d.ollama || {}
|
|
@@ -6586,6 +7040,8 @@ async function render() {
|
|
|
6586
7040
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
6587
7041
|
else if (r.name === 'new') await viewNewSession()
|
|
6588
7042
|
else if (r.name === 'project' && r.arg) await viewProject(r.arg)
|
|
7043
|
+
else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
|
|
7044
|
+
else if (r.name === 'testplan' && r.arg) await viewTestplan(r.arg, r.params) // testplan Part 3
|
|
6589
7045
|
else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
|
|
6590
7046
|
else if (r.name === 'setup') await viewSetup()
|
|
6591
7047
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
@@ -6620,6 +7076,7 @@ async function viewProject(token) {
|
|
|
6620
7076
|
const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
|
|
6621
7077
|
qaUi.page = page
|
|
6622
7078
|
paintProject()
|
|
7079
|
+
if (page && page.scope && (page.scope.kind === 'project' || page.decisionRepo)) loadProjectDecisions(token) // 2026-11-05 a single repo's page too
|
|
6623
7080
|
if (fresh) window.scrollTo(0, 0)
|
|
6624
7081
|
const r = state.route || {}
|
|
6625
7082
|
if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
|
|
@@ -6639,6 +7096,11 @@ function paintProject() {
|
|
|
6639
7096
|
const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
6640
7097
|
$app.innerHTML = out.html
|
|
6641
7098
|
fillAsk($app, out.keys)
|
|
7099
|
+
if (p.scope && (p.scope.kind === 'project' || p.decisionRepo)) { // 2026-11-03 decision memory: the project's log and where it's kept; 2026-11-05 a repo's log
|
|
7100
|
+
const at = document.getElementById('projAsks')
|
|
7101
|
+
if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
|
|
7102
|
+
paintProjectDecisions()
|
|
7103
|
+
}
|
|
6642
7104
|
const ta = document.getElementById('askIn')
|
|
6643
7105
|
if (ta) { ta.value = state.drafts['ask:' + ((p.scope || {}).key || '')] || ''; fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
6644
7106
|
}
|
|
@@ -7171,9 +7633,9 @@ async function startSend(btn) {
|
|
|
7171
7633
|
const box = btn.closest('.start-box')
|
|
7172
7634
|
const ta = box && box.querySelector('.start-text')
|
|
7173
7635
|
const text = ta ? ta.value.trim() : ''
|
|
7174
|
-
if (!text && !attachList(START_SID).length) { if (ta) ta.focus(); return }
|
|
7175
|
-
if (startUi.sending) return
|
|
7176
7636
|
const pick = startPick(startUi.data, startUi)
|
|
7637
|
+
if (!text && pick.agent !== 'codex' && !attachList(START_SID).length) { if (ta) ta.focus(); return } // 0.9.17 Codex may start with no first message
|
|
7638
|
+
if (startUi.sending) return
|
|
7177
7639
|
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
7178
7640
|
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
7179
7641
|
try {
|
|
@@ -7182,14 +7644,21 @@ async function startSend(btn) {
|
|
|
7182
7644
|
await uploadsSettled(START_SID)
|
|
7183
7645
|
const atts = attachList(START_SID)
|
|
7184
7646
|
if (atts.some((a) => a.status === 'failed')) throw new Error('An attachment didn’t upload. Retry it or remove it.')
|
|
7185
|
-
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: startUi.draft, atts }), { timeoutMs: 60000 })
|
|
7647
|
+
const r = await api('POST', '/v1/sessions/start', startBody(pick, text, { draft: pick.agent === 'codex' ? null : startUi.draft, atts: pick.agent === 'codex' ? [] : atts }), { timeoutMs: 60000 })
|
|
7186
7648
|
lastStartWrite(pick) // 0.9.13 ⌘K's New session… starts from here next time
|
|
7187
7649
|
if (atts.length) { attachUi.by.delete(START_SID); startUi.draft = null }
|
|
7188
7650
|
const xn = startExtrasNote(pick, r); if (xn) setTimeout(() => toast(xn, 6000), 1200) // 0.9.12: never a silent drop of a project's repo
|
|
7189
7651
|
if (state.drafts) delete state.drafts.start
|
|
7190
7652
|
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
7191
|
-
if (r.
|
|
7192
|
-
|
|
7653
|
+
if (r.app && !r.app.ok && r.app.message) setTimeout(() => toast(r.app.message, 7000), 900) // 1.0 My terminal app: it still runs (in the board's terminal)
|
|
7654
|
+
if (r.state === 'started' && r.sessionId) {
|
|
7655
|
+
startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); openStarted(r.sessionId, r.peerVia ? r.machine || pick.m.machine : null, text)
|
|
7656
|
+
if (r.run === 'board' && r.terminal && r.terminal.id && !r.peerVia) setTimeout(() => terminal.show(r.terminal.id).catch(() => {}), 50) // 1.0 Board terminal: shown now
|
|
7657
|
+
return
|
|
7658
|
+
}
|
|
7659
|
+
// 0.9.17 Codex: it runs in the board's terminal (shown now, on this Mac's board), and its session opens once Codex writes it
|
|
7660
|
+
if (r.agent === 'codex' && r.terminal && r.terminal.id && !r.peerVia) { toast('Codex started on ' + (r.machine || pick.m.machine)); if (r.run !== 'app' || !(r.app && r.app.ok)) terminal.show(r.terminal.id).catch(() => {}) }
|
|
7661
|
+
startUi.ask = { ...r, machine: r.machine || pick.m.machine, prompt: text }
|
|
7193
7662
|
pollStartAsk()
|
|
7194
7663
|
} catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
|
|
7195
7664
|
if (state.route && state.route.name === 'new') render()
|
|
@@ -8471,6 +8940,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8471
8940
|
if (action === 'wt-close') return wtClose()
|
|
8472
8941
|
if (action === 'wt-stop') { const sid = btn.dataset.id; btn.disabled = true; const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/walkthrough/stop').catch(() => null); if (!r || !r.stopped) { const k = wtKey(sid); wtUi.loading.delete(k); wtUi.error.set(k, 'Stopped waiting. It may still finish: open it again in a minute.'); wtUi.sig = ''; wtFill(wtSession(sid)) } return }
|
|
8473
8942
|
if (action === 'wt-scope') { if (btn.disabled) return; wtUi.fellBack.delete(wtUi.sid); wtUi.scope.set(wtUi.sid, btn.dataset.scope); wtUi.filter = 'all'; wtRemember(); return wtLoad(wtUi.sid) }
|
|
8943
|
+
if (action === 'wt-files') { if (btn.disabled) return; return setFileScope(btn.dataset.files, { from: 'wt', id: wtUi.sid }) }
|
|
8474
8944
|
if (action === 'wt-view') { wtUi.view = btn.dataset.view === 'files' ? 'files' : 'story'; wtRemember(); return wtFill(wtSession(wtUi.sid)) }
|
|
8475
8945
|
if (action === 'wt-filter') { wtUi.filter = btn.dataset.filter || 'all'; return wtFill(wtSession(wtUi.sid)) }
|
|
8476
8946
|
if (action === 'wt-reviewed') return wtReviewed(wtUi.sid, btn.dataset.step)
|
|
@@ -8501,7 +8971,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8501
8971
|
const sm = d.querySelector('summary'); if (sm) sm.focus({ preventScroll: true })
|
|
8502
8972
|
return
|
|
8503
8973
|
}
|
|
8504
|
-
if (action === 'wt-go') { ev.preventDefault(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null }) }
|
|
8974
|
+
if (action === 'wt-go') { ev.preventDefault(); ev.stopPropagation(); return wtGo(wtUi.sid, btn.dataset.step || null, { group: btn.dataset.group || null, file: btn.dataset.file || null }) }
|
|
8505
8975
|
if (action === 'wt-step') return wtStep(Number(btn.dataset.dir) || 1)
|
|
8506
8976
|
// 2026-10-30 walkthrough round 3
|
|
8507
8977
|
if (action === 'wt-dmode') { try { if (btn.dataset.mode === 'split') localStorage.setItem('lo-wt-split', '1'); else localStorage.removeItem('lo-wt-split') } catch {} wtUi.sig = ''; return void wtFill(wtSession(wtUi.sid)) }
|
|
@@ -8561,6 +9031,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8561
9031
|
// 0.9.8 What it did
|
|
8562
9032
|
if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
|
|
8563
9033
|
if (action === 'wid-refresh') return loadWid(id)
|
|
9034
|
+
if (action === 'wid-files') return setFileScope(btn.dataset.files, { from: 'wid', id })
|
|
8564
9035
|
if (action === 'wid-tool') {
|
|
8565
9036
|
const ui = widOf(id), t = btn.dataset.tool
|
|
8566
9037
|
ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
|
|
@@ -8601,6 +9072,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
8601
9072
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
8602
9073
|
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
8603
9074
|
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
9075
|
+
if (action === 'prs-pick') { const u = btn.dataset.url; if (prsUi.picks.has(u)) prsUi.picks.delete(u); else prsUi.picks.add(u); return paintPrsBody() } // testplan Part 3
|
|
9076
|
+
if (action === 'prs-pick-clear') { prsUi.picks.clear(); return paintPrsBody() }
|
|
8604
9077
|
if (action === 'prs-older') { const d = prsUi.data[prsUi.view]; if (!d) return; d.olderLoading = true; btn.disabled = true; btn.textContent = 'Loading…'; prsUi.done[prsUi.view] = (Number(d.doneShown) || 0) + 100; prsUi.at[prsUi.view] = 0; return render() } // 0.9.12
|
|
8605
9078
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
8606
9079
|
if (action === 'pr-unlink') {
|
|
@@ -8742,7 +9215,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8742
9215
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
8743
9216
|
if (action === 'start-pick') {
|
|
8744
9217
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
8745
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
|
|
9218
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null; Object.assign(startUi, { extra: [], adding: false, saving: false, find: '' }); if (attachList(START_SID).length) setTimeout(startReupload, 0) /* 0.9.13: its files go to that Mac */ } else if (k === 'project') { startUi.project = v || null; startUi.repo = null; startUi.drop = []; startUi.custom = null; startUi.browse = null; Object.assign(startUi, { extra: [], adding: false, saving: false }) } else if (k === 'repo') { startUi.repo = v; startUi.extra = startUi.extra.filter((x) => x !== v) } else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v; else if (k === 'agent') startUi.agent = v === 'codex' ? 'codex' : 'claude' /* 0.9.17 */; else if (k === 'run') startUi.run = ['headless', 'board', 'app'].includes(v) ? v : null // 1.0
|
|
8746
9219
|
return render()
|
|
8747
9220
|
}
|
|
8748
9221
|
// 0.9.16 + Add another repo, and Save these as a project…
|
|
@@ -8821,6 +9294,21 @@ document.addEventListener('click', async (ev) => {
|
|
|
8821
9294
|
return render()
|
|
8822
9295
|
}
|
|
8823
9296
|
if (action === 'ask-auto') return autoRestClick(btn)
|
|
9297
|
+
// 1.0 Continue in terminal / Continue headless (src/run-switch.js)
|
|
9298
|
+
if (action === 'run-continue') {
|
|
9299
|
+
const run = btn.dataset.run === 'headless' ? 'headless' : 'board'
|
|
9300
|
+
if (!id || hlPending.has(id) || switchPending.has(id)) return
|
|
9301
|
+
switchPending.add(id); btn.disabled = true
|
|
9302
|
+
toast(run === 'headless' ? 'Ending it in the terminal, then continuing headless…' : 'Continuing in a terminal…', 3000)
|
|
9303
|
+
try {
|
|
9304
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/continue', { run }, { timeoutMs: 45000 })
|
|
9305
|
+
toast(run === 'headless' ? 'Continuing headless: talk to it here' : 'In the terminal now', 3500)
|
|
9306
|
+
if (r && r.terminal && r.terminal.id && run !== 'headless') terminal.show(r.terminal.id)
|
|
9307
|
+
} catch (e) { toast(e.message, 6000) }
|
|
9308
|
+
switchPending.delete(id)
|
|
9309
|
+
repaintSession(id)
|
|
9310
|
+
return render()
|
|
9311
|
+
}
|
|
8824
9312
|
if (action === 'headless-stop' || action === 'headless-run') {
|
|
8825
9313
|
const op = action === 'headless-stop' ? 'stop' : 'resume'
|
|
8826
9314
|
if (!id || hlPending.has(id)) return
|
|
@@ -9114,7 +9602,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
9114
9602
|
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
9115
9603
|
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
9116
9604
|
if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
|
|
9117
|
-
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.
|
|
9605
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
|
|
9118
9606
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
9119
9607
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
9120
9608
|
if (action === 'att-open') return openAttachment(btn)
|
|
@@ -9454,7 +9942,7 @@ function docViewerEl() {
|
|
|
9454
9942
|
document.body.appendChild(el)
|
|
9455
9943
|
return el
|
|
9456
9944
|
}
|
|
9457
|
-
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
9945
|
+
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
9458
9946
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
9459
9947
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
9460
9948
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -9721,6 +10209,9 @@ async function needDismissClick(btn) {
|
|
|
9721
10209
|
|
|
9722
10210
|
// Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
|
|
9723
10211
|
function goClick(btn, id, s) {
|
|
10212
|
+
// 0.9.17 a session in the board's own terminal (a Codex start): show that terminal, here, on any device
|
|
10213
|
+
// 1.0 unless a terminal app is attached to it (leftoff attach): then Go brings that window forward on its Mac
|
|
10214
|
+
if (s && s.isLocal !== false && s.terminal && s.terminal.kind === 'leftoff' && s.terminal.termId && !(s.terminal.apps > 0 && state.viewerLocal !== false)) return terminal.show(s.terminal.termId).then((ok) => { if (!ok) toast('Its terminal in Leftoff isn’t running any more', 4000) })
|
|
9724
10215
|
return goWithFeedback({
|
|
9725
10216
|
id,
|
|
9726
10217
|
title: s && s.title,
|