@leftoff/cli 0.9.17-beta.8 → 0.9.17-beta.9
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 +2 -0
- package/bin/leftoff.js +299 -299
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +77 -0
- package/ui/app.js +108 -35
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/side-fold.js +124 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.9",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/core.js
CHANGED
|
@@ -1672,7 +1672,9 @@ export function joinListLines(t) {
|
|
|
1672
1672
|
// last error, edited files not yet committed) or empty.
|
|
1673
1673
|
export function heuristicCheckpoint(messages, session = {}, { length = 'short', cwd } = {}) {
|
|
1674
1674
|
const ms_ = (messages || []).map((m) => (m && m.role === 'user' && typeof m.text === 'string' && /<\/?channel\b/i.test(m.text) ? { ...m, text: stripChannel(m.text) } : m))
|
|
1675
|
-
|
|
1675
|
+
// 2026-10-29: Claude Code's own wrappers (<task-notification>, <system-reminder>…) aren't what you asked for,
|
|
1676
|
+
// so they never become "Working on: “<task-notification>…”"
|
|
1677
|
+
const lastUser = [...ms_].reverse().find((m) => m.role === 'user' && m.text && m.text.trim() && !NOT_A_PROMPT.test(m.text))
|
|
1676
1678
|
const lastAssistant = [...ms_].reverse().find((m) => m.role === 'assistant' && m.text && m.text.trim())
|
|
1677
1679
|
let lastError = null
|
|
1678
1680
|
for (let i = ms_.length - 1; i >= 0 && !lastError; i--) {
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.17-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.17-beta.9' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.9",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -5132,3 +5132,80 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5132
5132
|
.relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
|
|
5133
5133
|
.relay-use { min-height: 44px; margin-left: auto; }
|
|
5134
5134
|
@media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
|
|
5135
|
+
|
|
5136
|
+
/* 2026-10-29 a calmer session panel (ui/side-fold.js): a slim pinned head, then the top open (Catch me up,
|
|
5137
|
+
the newest checkpoint's Stopped + Next, PRs, To do), the rest one-line headers with counts, closed until opened. */
|
|
5138
|
+
.fs-side-in > .fs-head { position: sticky; top: -10px; z-index: 4; margin: -10px -16px 4px; padding: 10px 16px 0; background: var(--bg); }
|
|
5139
|
+
.fs-side.is-scrolled .fs-side-in > .fs-head { padding-top: 2px; box-shadow: 0 1px 0 var(--line); }
|
|
5140
|
+
.fs-side.is-scrolled .fs-head .s-title-text { -webkit-line-clamp: 1; line-clamp: 1; }
|
|
5141
|
+
.fs-side.is-scrolled .fs-head-row { margin: 0; }
|
|
5142
|
+
.fs-side.is-scrolled .fs-head .sub { flex-wrap: nowrap; overflow: hidden; margin: 0 0 2px; gap: 5px; }
|
|
5143
|
+
.fs-side.is-scrolled .fs-head .sub > * { flex: none; height: 20px; }
|
|
5144
|
+
.fs-side.is-scrolled .fs-head .sub > .s-tsrc { display: none; }
|
|
5145
|
+
@media (max-width: 600px) { .fs-side-in > .fs-head { position: static; margin: 0 0 4px; padding: 0; box-shadow: none; background: none; } } /* the sheet's own head (the peek) is what stays */
|
|
5146
|
+
.fs-fold { border-top: 1px solid var(--line); margin: 0; }
|
|
5147
|
+
.fs-fold > .fs-fold-h { list-style: none; display: flex; align-items: center; gap: 6px; min-height: 44px; margin: 0; padding: 0 2px; cursor: pointer; font-size: 13.5px; font-weight: 600; line-height: 1; color: var(--text2); border-radius: 6px; -webkit-tap-highlight-color: transparent; }
|
|
5148
|
+
@media (pointer: fine) { .fs-fold > .fs-fold-h { min-height: 34px; } }
|
|
5149
|
+
.fs-fold > .fs-fold-h::-webkit-details-marker { display: none; }
|
|
5150
|
+
.fs-fold > .fs-fold-h::marker { content: ''; }
|
|
5151
|
+
.fs-fold-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5152
|
+
.fs-fold-n { font-weight: 400; color: var(--muted); }
|
|
5153
|
+
.fs-fold-c { flex: none; width: 14px; height: 14px; color: var(--faint); transition: transform .15s ease; }
|
|
5154
|
+
.fs-fold[open] > .fs-fold-h .fs-fold-c { transform: rotate(90deg); }
|
|
5155
|
+
@media (hover: hover) { .fs-fold > .fs-fold-h:hover { color: var(--text); } }
|
|
5156
|
+
.fs-fold > .fs-fold-h:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
5157
|
+
.fs-fold-b { padding: 2px 0 12px; }
|
|
5158
|
+
.fs-fold-b > .sfiles > h2:first-child { display: none; }
|
|
5159
|
+
.fs-fold-b > .slists > h2:first-child { margin-top: 0; }
|
|
5160
|
+
.fs-fold-b > .park-row, .fs-fold-b > .spacepick { margin-top: 0; }
|
|
5161
|
+
.fs-fold.fs-fold-in { border-top: 0; margin-top: 6px; }
|
|
5162
|
+
.fs-fold.fs-fold-in > .fs-fold-h { font-weight: 500; font-size: 13px; color: var(--muted); }
|
|
5163
|
+
@media (prefers-reduced-motion: reduce) { .fs-fold-c { transition: none; } }
|
|
5164
|
+
/* the newest checkpoint: Stopped + Next; More holds the rest */
|
|
5165
|
+
.fs-cp-more > summary { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; }
|
|
5166
|
+
@media (pointer: coarse) { .fs-cp-more > summary { min-height: 44px; } }
|
|
5167
|
+
.fs-cp-more[open] > summary { margin-bottom: 4px; }
|
|
5168
|
+
.files .files-more { display: contents; }
|
|
5169
|
+
.files .files-more > summary { list-style: none; display: inline-flex; align-items: center; height: 24px; line-height: 1; padding: 0 6px; font-size: 12px; cursor: pointer; color: var(--accent); }
|
|
5170
|
+
.files .files-more > summary::-webkit-details-marker { display: none; }
|
|
5171
|
+
.files .files-more[open] > summary { display: none; }
|
|
5172
|
+
.files .chip.mono { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5173
|
+
/* status history: one small shape per change; the word and time in its tip */
|
|
5174
|
+
.sstrip { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; padding: 2px 0; }
|
|
5175
|
+
.sstrip .ss { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; line-height: 1; font-size: 10px; border-radius: 4px; background: none; cursor: default; }
|
|
5176
|
+
.sstrip .ss:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
5177
|
+
/* answered: the newest three, Show all N */
|
|
5178
|
+
.sanswered .answered { margin: 4px 0; }
|
|
5179
|
+
.answered-more > summary { list-style: none; display: inline-flex; align-items: center; min-height: 32px; font-size: 12.5px; cursor: pointer; color: var(--accent); }
|
|
5180
|
+
.answered-more > summary::-webkit-details-marker { display: none; }
|
|
5181
|
+
.answered-more[open] > summary { display: none; }
|
|
5182
|
+
@media (pointer: coarse) { .answered-more > summary { min-height: 44px; } }
|
|
5183
|
+
/* PRs: one compact row each (state, #, the title on one line); tap the title for the rest */
|
|
5184
|
+
.pr-compact .prrow { padding: 0 0 0 10px; gap: 6px; margin-bottom: 6px; min-height: 44px; }
|
|
5185
|
+
@media (pointer: fine) { .pr-compact .prrow { min-height: 34px; } }
|
|
5186
|
+
.pr-compact .prrow.open { padding-top: 8px; padding-bottom: 8px; align-items: flex-start; }
|
|
5187
|
+
.pr-compact .pr-main { flex: 1 1 auto; min-width: 0; }
|
|
5188
|
+
.pr-compact .pr-c1 { display: flex; align-items: center; gap: 7px; min-width: 0; height: 32px; font-size: 13px; line-height: 1; }
|
|
5189
|
+
.pr-compact .pr-c1 .pr-state, .pr-compact .pr-c1 .pr-num { flex: none; font-size: 12.5px; }
|
|
5190
|
+
.pr-compact .pr-c1 .pr-num { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
5191
|
+
.pr-compact .pr-title { flex: 1 1 auto; min-width: 0; margin: 0; overflow: hidden; }
|
|
5192
|
+
.pr-compact .pr-title-btn { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 500; color: var(--text); }
|
|
5193
|
+
.pr-compact .pr-meta { margin-top: 2px; font-size: 12px; }
|
|
5194
|
+
.pr-compact .pr-actions { margin: 0 -2px 0 0; }
|
|
5195
|
+
@media (pointer: fine) { .pr-compact .pr-actions .pr-ic { width: 32px; height: 32px; } }
|
|
5196
|
+
.sprs .pr-unfold { margin: 0 0 8px; }
|
|
5197
|
+
.sprs .pr-unfold > .pr-unsum { list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; line-height: 1; padding: 0 10px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
|
|
5198
|
+
@media (pointer: coarse) { .sprs .pr-unfold > .pr-unsum { height: 44px; } }
|
|
5199
|
+
.sprs .pr-unfold > .pr-unsum::-webkit-details-marker { display: none; }
|
|
5200
|
+
.sprs .pr-unfold > .pr-unsum:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5201
|
+
.sprs .pr-unfold[open] > .pr-unsum { margin-bottom: 6px; }
|
|
5202
|
+
.sprs .pr-linkd > summary { list-style: none; display: inline-flex; align-items: center; min-height: 32px; font-size: 12.5px; cursor: pointer; color: var(--accent); }
|
|
5203
|
+
@media (pointer: coarse) { .sprs .pr-linkd > summary { min-height: 44px; } }
|
|
5204
|
+
.sprs .pr-linkd > summary::-webkit-details-marker { display: none; }
|
|
5205
|
+
.sprs .pr-linkd[open] > summary { display: none; }
|
|
5206
|
+
/* a path the board can't read: plain monospace, why in its tip (never a viewer that opens onto "not available") */
|
|
5207
|
+
.file-ref-off { font-family: var(--mono); font-size: .94em; color: var(--text2); cursor: default; overflow-wrap: anywhere; }
|
|
5208
|
+
code .file-ref-off { font-size: inherit; }
|
|
5209
|
+
.file-ref-off:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 3px; }
|
|
5210
|
+
.fs-side-in > .wid { border-top: 1px solid var(--line); margin-top: 4px; }
|
|
5211
|
+
.fs-side-in > .wid .wid-toggle h2 { text-transform: none; letter-spacing: normal; font-size: 13.5px; font-weight: 600; color: var(--text2); }
|
package/ui/app.js
CHANGED
|
@@ -52,6 +52,8 @@ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
|
52
52
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
53
53
|
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
|
|
54
54
|
import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
|
|
55
|
+
import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
|
|
56
|
+
import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
|
|
55
57
|
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName } from './semdiff.js' // 0.9.17 Walk me through it
|
|
56
58
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
57
59
|
import { lastReply, ghostShows } from './ghost.js'
|
|
@@ -335,6 +337,8 @@ const ORDER = [
|
|
|
335
337
|
['stale', 'Stale'],
|
|
336
338
|
['ended', 'Ended today'],
|
|
337
339
|
]
|
|
340
|
+
const sideFolds = createFoldStore(typeof localStorage !== 'undefined' ? localStorage : null) // 2026-10-29 the session panel's folds (ui/side-fold.js)
|
|
341
|
+
let fsScrolled = null // the session panel scrolled past its head (its id): a redraw keeps the slim pinned head
|
|
338
342
|
const LABEL = { needs_input: 'needs you', active: 'active', background: 'tasks running', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
|
|
339
343
|
|
|
340
344
|
// ------------------------------------------------------------ helpers
|
|
@@ -468,14 +472,14 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
468
472
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
469
473
|
const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
470
474
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
471
|
-
function cpExtras(cp) {
|
|
475
|
+
function cpExtras(cp, paths = null) {
|
|
472
476
|
let h = ''
|
|
473
477
|
for (const [k, title] of EXTRA_TITLES) {
|
|
474
478
|
const v = cp[k]
|
|
475
479
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
476
480
|
h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
|
|
477
481
|
if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
|
|
478
|
-
else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
|
|
482
|
+
else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(paths ? relPath(f.path, paths) : f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
|
|
479
483
|
else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
|
|
480
484
|
h += '</div>'
|
|
481
485
|
}
|
|
@@ -486,7 +490,9 @@ function cpExtras(cp) {
|
|
|
486
490
|
// md (0.9.5): the session page's full view renders a markdown checkpoint (a
|
|
487
491
|
// model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
|
|
488
492
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
489
|
-
|
|
493
|
+
// part (2026-10-29, the session panel's newest checkpoint): 'tail' is Stopped + Next only, 'head' Goal + Doing only.
|
|
494
|
+
// paths: { roots, home } so a file reads relative to the session's repo (ui/side-fold.js relPath).
|
|
495
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false, rows = null, part = null, paths = null } = {}) {
|
|
490
496
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
491
497
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
492
498
|
const you = /^YOU:/i.test(cp.next || '')
|
|
@@ -500,6 +506,8 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
500
506
|
}
|
|
501
507
|
// 0.9.15: what the session is for and how far along, above its latest step
|
|
502
508
|
const goal = rows && !rows.goal ? '' : String(cp.goal || '').trim(), prog = String(cp.progress || '').trim()
|
|
509
|
+
if (part === 'tail') return '<dl class="cp cp-tail' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '"><dt>Stopped</dt>' + dd(cp.stoppedAt) + (String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') + '</dl>'
|
|
510
|
+
if (part === 'head') return (goal || String(cp.doing || '').trim() ? '<dl class="cp cp-head">' + (goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') + (String(cp.doing || '').trim() ? '<dt>Doing</dt>' + dd(cp.doing) : '') + '</dl>' : '') + (n ? '<div class="cpx">' + cpExtras(cp, paths) + '</div>' : '')
|
|
503
511
|
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
504
512
|
(goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
|
|
505
513
|
(rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
|
|
@@ -507,7 +515,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
507
515
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
508
516
|
(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>' : '') +
|
|
509
517
|
'</dl>' +
|
|
510
|
-
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp) + '</div></details>' : '')
|
|
518
|
+
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp, paths) + '</div></details>' : '')
|
|
511
519
|
}
|
|
512
520
|
|
|
513
521
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -2068,7 +2076,7 @@ async function viewWaiting() {
|
|
|
2068
2076
|
(recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
|
|
2069
2077
|
(r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
|
|
2070
2078
|
'<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
|
|
2071
|
-
(r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
|
|
2079
|
+
(r.where ? '<span class="where">' + esc(whereWords(r.where)) + '</span>' : '') + '</li>').join('') + '</ul>'
|
|
2072
2080
|
: '<div class="nocp">Nothing yet since the daemon started.</div>') +
|
|
2073
2081
|
'<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
|
|
2074
2082
|
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
@@ -2150,13 +2158,10 @@ document.addEventListener('keydown', (ev) => {
|
|
|
2150
2158
|
const b = document.querySelector('[data-action="agent-add"]')
|
|
2151
2159
|
if (b && !b.disabled) b.click()
|
|
2152
2160
|
})
|
|
2161
|
+
// 2026-10-29: the newest three, then Show all N; where each came from in true words (ui/side-fold.js)
|
|
2153
2162
|
function answeredList(s) {
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2156
|
-
return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
|
|
2157
|
-
'<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
|
|
2158
|
-
'<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
|
|
2159
|
-
'<div class="hint">Also in answers.md in this session’s folder.</div>'
|
|
2163
|
+
const body = answeredHtml(s.answers, { clock }, { esc })
|
|
2164
|
+
return body ? '<section class="sanswered" id="answered">' + body + '</section>' : ''
|
|
2160
2165
|
}
|
|
2161
2166
|
|
|
2162
2167
|
// External agents (v0.5.4): a feed session's page. Everything is the feed's
|
|
@@ -2550,9 +2555,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2550
2555
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2551
2556
|
'<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>' +
|
|
2552
2557
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2553
|
-
'<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>' +
|
|
2558
|
+
'<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>' +
|
|
2559
|
+
// 2026-10-29: the head above (title row, the status and Live chips) stays pinned while the panel scrolls; these scroll
|
|
2554
2560
|
(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>' : '') +
|
|
2555
|
-
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
2561
|
+
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>'
|
|
2556
2562
|
// the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
|
|
2557
2563
|
noteOpenAway(s)
|
|
2558
2564
|
const awayMs = openAway.get(s.id)
|
|
@@ -2576,33 +2582,44 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2576
2582
|
const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
|
|
2577
2583
|
const wr = writingFor(s) // 0.9.5: the one being written, at the top until it lands
|
|
2578
2584
|
const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2585
|
+
// 2026-10-29: a wrapper checkpoint ("Working on: <task-notification>…") written beside a real one isn't shown
|
|
2586
|
+
const cps = dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
2587
|
+
const paths = { roots: rootsOf(s), home: (state.health && state.health.home) || '' }
|
|
2588
|
+
// 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
|
|
2589
|
+
side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
|
|
2590
|
+
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>'
|
|
2591
|
+
if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
|
|
2582
2592
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2583
2593
|
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>'
|
|
2584
2594
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
2585
2595
|
if (empties.length) side += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
|
|
2586
2596
|
side += '</section>'
|
|
2597
|
+
// 2026-10-29 a calmer panel: PRs and To do stay open; the rest is a one-line header with a count, closed
|
|
2598
|
+
// until you open it (kept per device: ui/side-fold.js). Each keeps its id, so its painter still finds it.
|
|
2599
|
+
side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
2600
|
+
side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
|
|
2587
2601
|
// 0.9.8: the last tools it ran and its folder's changes; loads when you open it
|
|
2588
2602
|
side += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
|
|
2603
|
+
const fold = (key, title, count, body) => foldHtml({ key, title, count, body, open: sideFolds.open(key) }, { esc })
|
|
2604
|
+
const fl = s.files || {}, nFiles = (fl.artifacts || []).length + (fl.plans || []).length + (fl.docs || []).length
|
|
2605
|
+
const filesBody = sessionFilesSection(s, { agoFn: ago })
|
|
2606
|
+
side += filesBody ? fold('files', 'Files', nFiles || null, '<section class="sfiles" id="files">' + filesBody + '</section>') : '<section class="sfiles" id="files"></section>'
|
|
2607
|
+
if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
|
|
2608
|
+
state.listsMayEdit = state.mayAnswer !== false
|
|
2609
|
+
const listsBody = sessionListsSection(s)
|
|
2610
|
+
side += listsBody ? fold('lists', 'Lists', (s.lists || []).length || null, '<section class="slists" id="lists">' + listsBody + '</section>') : '<section class="slists" id="lists"></section>'
|
|
2611
|
+
side += fold('park', s.parked ? 'Parked' : 'Park', null, parkRow(s))
|
|
2612
|
+
side += fold('space', 'Space', s.space || null, spacePicker(s, spv, 'session', s.id))
|
|
2613
|
+
side += fold('status', 'Status history', (s.statusChanges || []).length || null, statusStripHtml(s.statusChanges, { label: LABEL, clock }, { esc }))
|
|
2614
|
+
side += fold('answered', 'Answered from the board', (s.answers || []).length || null, answeredList(s))
|
|
2589
2615
|
side += '<section class="fs-sec fs-details" id="fsDetails">' + detailsHtml( // 0.9.11: the facts, behind Details
|
|
2590
2616
|
fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Started', clock(s.startedAt)) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + fsOtherRepos(s) + repoTile(s) + branchTileHtml(branch, { esc }) + fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) +
|
|
2591
2617
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
|
|
2592
2618
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
|
|
2593
2619
|
(s.headless && !s.terminal && !s.tty ? fact('Terminal', 'None · started from the board', '') : fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' })), { open: dOpen }) + '</section>'
|
|
2594
|
-
side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
|
|
2595
|
-
side += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
2596
|
-
side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
2597
|
-
if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
|
|
2598
|
-
state.listsMayEdit = state.mayAnswer !== false
|
|
2599
|
-
side += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
2600
|
-
side += parkRow(s) + spacePicker(s, spv, 'session', s.id)
|
|
2601
|
-
if (s.statusChanges && s.statusChanges.length) side += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" data-tip="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
2602
|
-
side += answeredList(s)
|
|
2603
2620
|
const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
|
|
2604
2621
|
const label = fsPeekWords(s)
|
|
2605
|
-
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
|
|
2622
|
+
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, scrolled: fsScrolled === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
|
|
2606
2623
|
}
|
|
2607
2624
|
// The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
|
|
2608
2625
|
// "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
|
|
@@ -2611,7 +2628,7 @@ function fsStubHtml(s, inner, foot = '') {
|
|
|
2611
2628
|
return liveOpenHtml(s, null, '') + '<div class="live-thread fs-none" data-sid="' + esc(s.id) + '"><div class="fs-none-in">' + inner + '</div></div>' + foot + '</section>' // 0.9.17: an ended one keeps its Ended · Resume bar
|
|
2612
2629
|
}
|
|
2613
2630
|
function fsPeekWords(s) {
|
|
2614
|
-
const n = s.checkpointCount || 0
|
|
2631
|
+
const n = Array.isArray(s.checkpoints) ? dropJunkCps(s.checkpoints.filter((cp) => !cp.doubt)).length : s.checkpointCount || 0 // 2026-10-29: the junk twin isn't counted
|
|
2615
2632
|
const w = String(LABEL[s.status] || s.status || 'Session')
|
|
2616
2633
|
return w.charAt(0).toUpperCase() + w.slice(1) + ' · ' + n + ' checkpoint' + (n === 1 ? '' : 's')
|
|
2617
2634
|
}
|
|
@@ -2986,7 +3003,24 @@ function fsRailGo(go) {
|
|
|
2986
3003
|
if (el) el.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
2987
3004
|
const d = el && el.querySelector('details:not([open])')
|
|
2988
3005
|
if (d && go === 'fsDetails') d.open = true
|
|
2989
|
-
|
|
3006
|
+
const f = el && el.closest('details.fs-fold:not([open])') // 2026-10-29: a folded section opens when the rail goes to it
|
|
3007
|
+
if (f) f.open = true
|
|
3008
|
+
}
|
|
3009
|
+
// 2026-10-29 the panel's folds: open or closed as you leave them, on this device (ui/side-fold.js)
|
|
3010
|
+
document.addEventListener('toggle', (e) => {
|
|
3011
|
+
const d = e.target
|
|
3012
|
+
if (!d || !d.matches || !d.matches('#fsSide details[data-fold]')) return
|
|
3013
|
+
sideFolds.set(d.dataset.fold, d.open)
|
|
3014
|
+
}, true)
|
|
3015
|
+
// The pinned head's title goes to one line once the panel scrolls, so the pinned part stays slim.
|
|
3016
|
+
document.addEventListener('scroll', (e) => {
|
|
3017
|
+
const el = e.target
|
|
3018
|
+
if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
|
|
3019
|
+
const side = el.closest('.fs-side')
|
|
3020
|
+
fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
|
|
3021
|
+
if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
|
|
3022
|
+
}, true)
|
|
3023
|
+
|
|
2990
3024
|
function toggleFsSheet() {
|
|
2991
3025
|
const side = document.getElementById('fsSide')
|
|
2992
3026
|
if (!side) return
|
|
@@ -4044,16 +4078,29 @@ async function aroundWire(id) {
|
|
|
4044
4078
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4045
4079
|
}
|
|
4046
4080
|
}
|
|
4047
|
-
function cpTimeline(list, newestCp, wtSid = null) {
|
|
4081
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null) {
|
|
4048
4082
|
return list.map((cp) =>
|
|
4049
|
-
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"
|
|
4050
|
-
(cp
|
|
4051
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
|
|
4083
|
+
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
|
|
4084
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true, paths }) +
|
|
4052
4085
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4053
|
-
(cp.filesTouched
|
|
4086
|
+
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4054
4087
|
(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
|
|
4055
4088
|
'</div>').join('')
|
|
4056
4089
|
}
|
|
4090
|
+
function cpHeadLine(cp) {
|
|
4091
|
+
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>' +
|
|
4092
|
+
(cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
|
|
4093
|
+
}
|
|
4094
|
+
// 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
|
|
4095
|
+
// your note and the files behind More (open or closed as you last left it on this device).
|
|
4096
|
+
function cpLatestHtml(cp, wtSid, paths) {
|
|
4097
|
+
const more = cpBlock(cp, { md: true, part: 'head', paths }) + (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') + filesTouchedHtml(cp.filesTouched, paths || {}, { esc })
|
|
4098
|
+
return '<div class="tl-item tl-latest ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '">' + cpHeadLine(cp) +
|
|
4099
|
+
cpBlock(cp, { showWhen: false, md: true, part: 'tail', paths }) +
|
|
4100
|
+
(more ? '<details class="cp-more fs-cp-more" data-fold="cp-more"' + (sideFolds.open('cp-more') ? ' open' : '') + '><summary>More</summary>' + more + '</details>' : '') +
|
|
4101
|
+
(wtSid ? '<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>' : '') +
|
|
4102
|
+
'</div>'
|
|
4103
|
+
}
|
|
4057
4104
|
// Space of one session (or, on an agent's page, of that agent's feed): its
|
|
4058
4105
|
// own override, or back to the repo / Mac default (v0.5.8).
|
|
4059
4106
|
function spacePicker(s, spv, kind, key) {
|
|
@@ -7462,7 +7509,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7462
7509
|
clearTimeout(timer); cur = el
|
|
7463
7510
|
timer = setTimeout(() => {
|
|
7464
7511
|
if (cur !== el || !el.isConnected) return hide()
|
|
7465
|
-
if (el.matches('.wt-st-h h3') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
|
|
7512
|
+
if (el.matches('.wt-st-h h3, .pr-compact .pr-title-btn') && el.scrollWidth <= el.clientWidth) return // a step's title only when it's cut short
|
|
7466
7513
|
tip.textContent = el.dataset.tip
|
|
7467
7514
|
const r = el.getBoundingClientRect(), w = tip.offsetWidth
|
|
7468
7515
|
tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
|
|
@@ -7470,7 +7517,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7470
7517
|
tip.classList.add('on')
|
|
7471
7518
|
}, delay)
|
|
7472
7519
|
}
|
|
7473
|
-
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
7520
|
+
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip], .sstrip .ss[data-tip], .file-ref-off[data-tip], .pr-compact .pr-title-btn[data-tip], .files .chip[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
7474
7521
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
7475
7522
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
7476
7523
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
@@ -9175,8 +9222,34 @@ async function openToolOutput(sessionId, toolId, label) {
|
|
|
9175
9222
|
// daemon serves only the session's folder and Claude's scratch folders
|
|
9176
9223
|
// (src/file-ref.js). Centered on a desktop, a bottom sheet on a phone.
|
|
9177
9224
|
const refUi = { sid: null, path: null, line: null, r: null, from: null, seq: 0 }
|
|
9225
|
+
// 2026-10-29 Which session a path in the page belongs to, and whether this board can read its files
|
|
9226
|
+
// (this machine; another over the peer link; the hosted board straight from it): ui/file-reach.js.
|
|
9227
|
+
function refSidOf(el) {
|
|
9228
|
+
if (el.closest('#docViewer')) return refUi.sid
|
|
9229
|
+
const box = el.closest('[data-sid], #live[data-id]')
|
|
9230
|
+
return (box && (box.dataset.sid || box.dataset.id)) || sessionOfHash(location.hash) || liveOnScreen()
|
|
9231
|
+
}
|
|
9232
|
+
function refReachFor(el) {
|
|
9233
|
+
const sid = refSidOf(el)
|
|
9234
|
+
const s = sid && ((state.sessions || []).find((x) => x.id === sid) || (state.session && state.session.id === sid ? state.session : null))
|
|
9235
|
+
if (!s) return { ok: true }
|
|
9236
|
+
return fileReach(s, { relay: window.leftoffRelay && state.relayStatus ? state.relayStatus : null })
|
|
9237
|
+
}
|
|
9238
|
+
{
|
|
9239
|
+
let queued = false
|
|
9240
|
+
const pass = () => { queued = false; try { gateFileRefs(document.body, refReachFor) } catch {} }
|
|
9241
|
+
const mo = new MutationObserver((recs) => {
|
|
9242
|
+
if (queued) return
|
|
9243
|
+
if (!recs.some((r) => r.addedNodes.length)) return
|
|
9244
|
+
queued = true; requestAnimationFrame(pass)
|
|
9245
|
+
})
|
|
9246
|
+
if (document.body) mo.observe(document.body, { childList: true, subtree: true })
|
|
9247
|
+
// a path that can't be read: a tap says why (a phone has no hover tip)
|
|
9248
|
+
document.addEventListener('click', (e) => { const off = e.target.closest && e.target.closest('.file-ref-off'); if (off && matchMedia('(hover: none)').matches) toast(off.dataset.tip, 4000) })
|
|
9249
|
+
}
|
|
9178
9250
|
function openRefFile(btn, cardId) {
|
|
9179
9251
|
const inViewer = btn.closest('#docViewer')
|
|
9252
|
+
{ const r = inViewer ? { ok: true } : refReachFor(btn); if (!r.ok) { toast(r.tip, 4000); return } } // never a viewer that opens onto "not available"
|
|
9180
9253
|
let p = btn.dataset.path || ''
|
|
9181
9254
|
if (inViewer && refUi.r && refUi.r.path && /^\.{1,2}\//.test(p)) p = refAgainst(p, refUi.r.path) // ./ or ../ inside a viewed file: that file's folder (else the session's)
|
|
9182
9255
|
const sid = (inViewer && refUi.sid) || cardId || sessionOfHash(location.hash) || liveOnScreen()
|
package/ui/file-reach.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 2026-10-29 A file path in a message is a link only when the board can read
|
|
2
|
+
// that file (Joel: "in the chat all files are clickable and open in the modal
|
|
3
|
+
// but not available"). The board reads a session's file on the machine that
|
|
4
|
+
// has it: this machine; another machine over the signed, sealed peer link the
|
|
5
|
+
// Files panel already uses (src/engine.js sessionFile -> viaPeer); or, on the
|
|
6
|
+
// hosted board, straight from that machine through the relay
|
|
7
|
+
// (ui/relay-client.js ownerRoute). Nothing new is opened to the network here:
|
|
8
|
+
// this only decides whether to offer the link. Otherwise the path stays plain
|
|
9
|
+
// monospace text and its tip says why. Pure (test/unit/calmside-1029.test.mjs).
|
|
10
|
+
|
|
11
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
12
|
+
|
|
13
|
+
// s: the session (isLocal, machine, peer: { ok, code }, agent)
|
|
14
|
+
// relay: null off the hosted board; else { macs: [{ machine, online, serves, platform }] } from the relay's status
|
|
15
|
+
// -> { ok: true } | { ok: false, why: 'agent' | 'offline' | 'sandbox' | 'unreachable', tip }
|
|
16
|
+
export function fileReach(s, { relay = null } = {}) {
|
|
17
|
+
if (!s) return { ok: true } // no session to ask about: the viewer says what it can
|
|
18
|
+
if (s.agent || /^agent:/.test(String(s.id || ''))) return { ok: false, why: 'agent', tip: 'External agents don’t share files' }
|
|
19
|
+
if (s.isLocal !== false) return { ok: true }
|
|
20
|
+
const m = String(s.machine || 'its machine')
|
|
21
|
+
const x = relay && Array.isArray(relay.macs) ? relay.macs.find((y) => y && y.machine === m) : null
|
|
22
|
+
const peer = s.peer || {}
|
|
23
|
+
if (x && !x.online) return { ok: false, why: 'offline', tip: 'On ' + m + ' (offline). Open the board on ' + m + ' to view' }
|
|
24
|
+
if (peer.ok || peer.code === 'checking') return { ok: true }
|
|
25
|
+
// the hosted board goes straight to the session's machine when that machine can open this browser's key
|
|
26
|
+
if (x && x.online && x.serves !== 0) return { ok: true }
|
|
27
|
+
if (x && x.online) return { ok: false, why: 'sandbox', tip: x.platform && x.platform !== 'darwin' ? 'On ' + m + '. Can’t read files from a sandbox here; open the board on ' + m + ' to view' : 'On ' + m + ', which can’t share its files with this device yet' }
|
|
28
|
+
if (peer.code === 'unreachable' || !x) return { ok: false, why: peer.code === 'unreachable' ? 'offline' : 'unreachable', tip: peer.code === 'unreachable' ? 'On ' + m + ' (offline). Open the board on ' + m + ' to view' : 'On ' + m + ', which this board can’t reach right now' }
|
|
29
|
+
return { ok: false, why: 'unreachable', tip: 'On ' + m + ', which this board can’t reach right now' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The plain-text form of a path link (the words as shown; the path kept for when it can be read again)
|
|
33
|
+
export function offRefHtml({ path, line = null, shownHtml, tip }, { esc = escD } = {}) {
|
|
34
|
+
return '<span class="file-ref-off" tabindex="0" data-path="' + esc(path) + '"' + (line ? ' data-line="' + esc(String(line)) + '"' : '') + ' data-tip="' + esc(tip) + '" aria-label="' + esc(String(path) + '. ' + tip) + '">' + shownHtml + '</span>'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// In the page: every path link a session's machine can't serve becomes plain
|
|
38
|
+
// text, and back to a link once it can. reachFor(el) -> { ok, tip } for the
|
|
39
|
+
// session that element belongs to. Returns how many changed.
|
|
40
|
+
export function gateFileRefs(root, reachFor) {
|
|
41
|
+
if (!root || !root.querySelectorAll) return 0
|
|
42
|
+
let n = 0
|
|
43
|
+
for (const a of root.querySelectorAll('a.file-ref')) {
|
|
44
|
+
const r = reachFor(a)
|
|
45
|
+
if (!r || r.ok) continue
|
|
46
|
+
const sp = a.ownerDocument.createElement('span')
|
|
47
|
+
sp.className = 'file-ref-off'; sp.tabIndex = 0
|
|
48
|
+
sp.setAttribute('data-path', a.getAttribute('data-path') || '')
|
|
49
|
+
if (a.getAttribute('data-line')) sp.setAttribute('data-line', a.getAttribute('data-line'))
|
|
50
|
+
sp.setAttribute('data-tip', r.tip); sp.setAttribute('aria-label', (a.getAttribute('data-path') || '') + '. ' + r.tip)
|
|
51
|
+
while (a.firstChild) sp.appendChild(a.firstChild)
|
|
52
|
+
a.replaceWith(sp); n++
|
|
53
|
+
}
|
|
54
|
+
for (const sp of root.querySelectorAll('span.file-ref-off')) {
|
|
55
|
+
const r = reachFor(sp)
|
|
56
|
+
if (!r || !r.ok) { if (r && r.tip && sp.getAttribute('data-tip') !== r.tip) sp.setAttribute('data-tip', r.tip); continue }
|
|
57
|
+
const a = sp.ownerDocument.createElement('a')
|
|
58
|
+
a.className = 'file-ref'; a.href = '#'
|
|
59
|
+
a.setAttribute('data-action', 'file-ref'); a.setAttribute('data-path', sp.getAttribute('data-path') || '')
|
|
60
|
+
if (sp.getAttribute('data-line')) a.setAttribute('data-line', sp.getAttribute('data-line'))
|
|
61
|
+
a.setAttribute('data-tip', 'Open in Leftoff')
|
|
62
|
+
while (sp.firstChild) a.appendChild(sp.firstChild)
|
|
63
|
+
sp.replaceWith(a); n++
|
|
64
|
+
}
|
|
65
|
+
return n
|
|
66
|
+
}
|
package/ui/fs-side.js
CHANGED
|
@@ -71,12 +71,12 @@ export function railHtml({ cpCount = 0, cu = true, away = '' } = {}, { esc, icon
|
|
|
71
71
|
// The whole panel. side/foot: the panel's markup (built by app.js from the
|
|
72
72
|
// session); label: the peek's one line; collapsed: the rail instead (desktop);
|
|
73
73
|
// sheet: the phone's sheet is raised.
|
|
74
|
-
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
74
|
+
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, scrolled = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
75
75
|
const peek = '<div class="fs-peek">' +
|
|
76
76
|
'<button type="button" class="fs-peek-b" data-action="fs-sheet" aria-expanded="' + (sheet ? 'true' : 'false') + '" aria-controls="fsSide"><span class="fs-grip" aria-hidden="true"></span><span class="fs-peek-t">' + esc(label) + '</span>' + icon(sheet ? 'chevron-down' : 'chevron-up') + '</button>' +
|
|
77
77
|
(cu ? '<button type="button" class="btn fs-peek-cu' + (away ? ' primary' : '') + '" data-action="scu-open" data-id="' + esc(id) + '"' + (away ? ' aria-label="' + esc(away + ' · Catch me up') + '"' : '') + '>' + icon('coffee') + 'Catch me up</button>' : '') +
|
|
78
78
|
// 0.9.17 Walk me through it, beside Catch me up in the peek (its eye count when there's something to look at)
|
|
79
79
|
(wt ? '<button type="button" class="btn fs-peek-wt" data-action="wt-open" data-id="' + esc(id) + '" aria-label="' + esc('Walk me through it' + (look ? ', ' + look + ' to look closely at' : '')) + '" data-tip="Walk me through the code changes">' + icon('git-compare') + (look ? '<span class="fs-peek-n" aria-hidden="true">' + esc(String(look)) + '</span>' : '') + '</button>' : '') + '</div>'
|
|
80
|
-
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
80
|
+
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
81
81
|
resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
|
|
82
82
|
}
|
package/ui/live-view.js
CHANGED
|
@@ -168,7 +168,7 @@ export function senderLabel(m) {
|
|
|
168
168
|
if (!device && by.transport === 'local') device = 'this Mac'
|
|
169
169
|
if (!device && m.where) { // older entries: just the where line
|
|
170
170
|
const w = String(m.where)
|
|
171
|
-
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
171
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || (w.match(/^from (.+?) · (?:via relay|direct)\b/) || [])[1] || null // 2026-10-29: "from iPhone · via relay"
|
|
172
172
|
}
|
|
173
173
|
let label = 'You' + (device ? ' · from ' + device : '')
|
|
174
174
|
const path = PATHS[by.transport] || null
|
package/ui/prs.js
CHANGED
|
@@ -93,6 +93,11 @@ export async function prCopyClick(btn, env = {}) {
|
|
|
93
93
|
export function prMoreHtml(p, open = false) {
|
|
94
94
|
return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
|
|
95
95
|
}
|
|
96
|
+
// 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
|
|
97
|
+
export function prGhOnlyHtml(p) {
|
|
98
|
+
const url = safeUrl(p.url)
|
|
99
|
+
return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
|
|
100
|
+
}
|
|
96
101
|
export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
97
102
|
const url = safeUrl(p.url), n = '#' + (p.number || '')
|
|
98
103
|
if (!url && !unlinkId) return ''
|
|
@@ -113,21 +118,25 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
113
118
|
const n = list.length + unchecked.length
|
|
114
119
|
let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
|
|
115
120
|
if (list.length) {
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
// 2026-10-29 one compact row each: the state (shape + word), #, the title on one line; tap the title for
|
|
122
|
+
// the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
|
|
123
|
+
h += '<div class="prlist pr-compact">' + list.map((p) => {
|
|
118
124
|
const isOpen = !!(open && open.has && open.has(p.url))
|
|
119
125
|
return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
120
|
-
'<div class="pr-
|
|
121
|
-
'<
|
|
122
|
-
'<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + prMoreHtml(p, isOpen) + '</div>' +
|
|
123
|
-
(isOpen ?
|
|
124
|
-
prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
|
|
126
|
+
'<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
|
|
127
|
+
'<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
|
|
128
|
+
(isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
129
|
+
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
|
|
125
130
|
}).join('') + '</div>'
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
// several refs that couldn't be checked fold into one "+4 not checked" row
|
|
133
|
+
if (unchecked.length === 1) h += '<div class="pr-unlist">' + uncheckedHtml(unchecked[0]) + '</div>'
|
|
134
|
+
else if (unchecked.length > 1) h += '<details class="pr-unfold"><summary class="pr-unsum">' + STATE_IC.unchecked + '<span>+' + unchecked.length + ' not checked</span></summary><div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div></details>'
|
|
128
135
|
if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
|
|
129
136
|
(mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
|
|
130
|
-
|
|
137
|
+
const form = '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
138
|
+
// 2026-10-29: with PRs already linked, the box waits behind one quiet "Link another PR…"
|
|
139
|
+
if (mayEdit) h += n ? '<details class="pr-linkd"><summary class="linkish">Link another PR…</summary>' + form + '</details>' : form
|
|
131
140
|
return h
|
|
132
141
|
}
|
|
133
142
|
// 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
|