@leftoff/cli 0.9.17-beta.7 → 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 +6 -0
- package/bin/leftoff.js +419 -419
- 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 +107 -0
- package/ui/app.js +128 -36
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/hosted-by.js +50 -0
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/relay-client.js +96 -6
- 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
|
@@ -5102,3 +5102,110 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5102
5102
|
.ghost .ghost-k { flex: none; }
|
|
5103
5103
|
.ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
|
|
5104
5104
|
.ghost .ghost-k { align-self: center; }
|
|
5105
|
+
|
|
5106
|
+
/* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
|
|
5107
|
+
.hostedby { min-width: 0; }
|
|
5108
|
+
.hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
5109
|
+
.host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
|
|
5110
|
+
.host-row + .host-row { border-top: 1px solid var(--line); }
|
|
5111
|
+
.host-row.is-host { background: var(--bg2); }
|
|
5112
|
+
.host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
|
|
5113
|
+
.host-row.on .host-shape { color: var(--text); }
|
|
5114
|
+
.host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
|
|
5115
|
+
.host-sub { flex-basis: 100%; font-size: 12.5px; }
|
|
5116
|
+
.host-chip { font-weight: 600; }
|
|
5117
|
+
.pref-chip { border-style: dashed; }
|
|
5118
|
+
.host-make { flex: none; min-height: 44px; }
|
|
5119
|
+
.host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
|
|
5120
|
+
.hostedby > .hint { margin-top: 8px; }
|
|
5121
|
+
.host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
|
|
5122
|
+
.hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
|
|
5123
|
+
.hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
|
|
5124
|
+
.hp-opt.on::before { content: '✓ '; }
|
|
5125
|
+
.hp-opt:disabled { opacity: .55; cursor: default; }
|
|
5126
|
+
.hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5127
|
+
@media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
|
|
5128
|
+
.relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
|
|
5129
|
+
.relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
|
|
5130
|
+
.relay-host + .relay-host { border-top: 1px solid var(--line); }
|
|
5131
|
+
.relay-host-shape { width: 16px; text-align: center; flex: none; }
|
|
5132
|
+
.relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
|
|
5133
|
+
.relay-use { min-height: 44px; margin-left: auto; }
|
|
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
|
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
|
|
|
34
34
|
import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
|
|
35
35
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
36
36
|
import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
|
|
37
|
+
import { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
|
|
37
38
|
import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
|
|
38
39
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
39
40
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -51,6 +52,8 @@ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
|
51
52
|
import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
|
|
52
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'
|
|
53
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
|
|
54
57
|
import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName } from './semdiff.js' // 0.9.17 Walk me through it
|
|
55
58
|
import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
|
|
56
59
|
import { lastReply, ghostShows } from './ghost.js'
|
|
@@ -334,6 +337,8 @@ const ORDER = [
|
|
|
334
337
|
['stale', 'Stale'],
|
|
335
338
|
['ended', 'Ended today'],
|
|
336
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
|
|
337
342
|
const LABEL = { needs_input: 'needs you', active: 'active', background: 'tasks running', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
|
|
338
343
|
|
|
339
344
|
// ------------------------------------------------------------ helpers
|
|
@@ -467,14 +472,14 @@ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
467
472
|
// The sections an informative / long checkpoint adds below the three lines (v0.4.6).
|
|
468
473
|
const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
|
|
469
474
|
function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
|
|
470
|
-
function cpExtras(cp) {
|
|
475
|
+
function cpExtras(cp, paths = null) {
|
|
471
476
|
let h = ''
|
|
472
477
|
for (const [k, title] of EXTRA_TITLES) {
|
|
473
478
|
const v = cp[k]
|
|
474
479
|
if (!v || (Array.isArray(v) && !v.length)) continue
|
|
475
480
|
h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
|
|
476
481
|
if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
|
|
477
|
-
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>'
|
|
478
483
|
else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
|
|
479
484
|
h += '</div>'
|
|
480
485
|
}
|
|
@@ -485,7 +490,9 @@ function cpExtras(cp) {
|
|
|
485
490
|
// md (0.9.5): the session page's full view renders a markdown checkpoint (a
|
|
486
491
|
// model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
|
|
487
492
|
// rows (0.9.15, ui/away.js cardRows): which of Goal / Doing a card shows; omitted: all.
|
|
488
|
-
|
|
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 } = {}) {
|
|
489
496
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
490
497
|
cp = tidyCp(cp) // 0.9.9: an older code-made one (another Mac's, or from before) reads like a new one
|
|
491
498
|
const you = /^YOU:/i.test(cp.next || '')
|
|
@@ -499,6 +506,8 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
499
506
|
}
|
|
500
507
|
// 0.9.15: what the session is for and how far along, above its latest step
|
|
501
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>' : '')
|
|
502
511
|
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
503
512
|
(goal ? '<dt>Goal</dt><dd class="goal">' + fmtCode(goal) + (prog ? '<span class="cp-prog"> · ' + fmtCode(prog) + '</span>' : '') + '</dd>' : '') +
|
|
504
513
|
(rows && !rows.doing ? '' : '<dt>Doing</dt>' + dd(cp.doing)) +
|
|
@@ -506,7 +515,7 @@ function cpBlock(cp, { showWhen = true, more = false, open = false, md = false,
|
|
|
506
515
|
(String(cp.next || '').trim() ? '<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) : '') +
|
|
507
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>' : '') +
|
|
508
517
|
'</dl>' +
|
|
509
|
-
(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>' : '')
|
|
510
519
|
}
|
|
511
520
|
|
|
512
521
|
// External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
|
|
@@ -2067,7 +2076,7 @@ async function viewWaiting() {
|
|
|
2067
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>' +
|
|
2068
2077
|
(r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
|
|
2069
2078
|
'<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
|
|
2070
|
-
(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>'
|
|
2071
2080
|
: '<div class="nocp">Nothing yet since the daemon started.</div>') +
|
|
2072
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>'
|
|
2073
2082
|
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
@@ -2149,13 +2158,10 @@ document.addEventListener('keydown', (ev) => {
|
|
|
2149
2158
|
const b = document.querySelector('[data-action="agent-add"]')
|
|
2150
2159
|
if (b && !b.disabled) b.click()
|
|
2151
2160
|
})
|
|
2161
|
+
// 2026-10-29: the newest three, then Show all N; where each came from in true words (ui/side-fold.js)
|
|
2152
2162
|
function answeredList(s) {
|
|
2153
|
-
const
|
|
2154
|
-
|
|
2155
|
-
return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
|
|
2156
|
-
'<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>' : '') +
|
|
2157
|
-
'<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
|
|
2158
|
-
'<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>' : ''
|
|
2159
2165
|
}
|
|
2160
2166
|
|
|
2161
2167
|
// External agents (v0.5.4): a feed session's page. Everything is the feed's
|
|
@@ -2381,6 +2387,10 @@ async function viewSession(id) {
|
|
|
2381
2387
|
// 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
|
|
2382
2388
|
// each session, read before caughtUp folds its card; a new visit starts over.
|
|
2383
2389
|
const openAway = new Map()
|
|
2390
|
+
// 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
|
|
2391
|
+
// while you were away reads at least "day" depth (ui/away.js), but its label says
|
|
2392
|
+
// how long it has really gone unseen: never more than the session's own age.
|
|
2393
|
+
function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
|
|
2384
2394
|
function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
|
|
2385
2395
|
function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
|
|
2386
2396
|
function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
|
|
@@ -2545,9 +2555,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2545
2555
|
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2546
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>' +
|
|
2547
2557
|
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2548
|
-
'<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
|
|
2549
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>' : '') +
|
|
2550
|
-
'<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>'
|
|
2551
2562
|
// the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
|
|
2552
2563
|
noteOpenAway(s)
|
|
2553
2564
|
const awayMs = openAway.get(s.id)
|
|
@@ -2555,7 +2566,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2555
2566
|
const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
|
|
2556
2567
|
// 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
|
|
2557
2568
|
// else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
|
|
2558
|
-
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2569
|
+
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2559
2570
|
const wtOn = wtUi.sid === s.id
|
|
2560
2571
|
const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
|
|
2561
2572
|
// 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
|
|
@@ -2571,33 +2582,44 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2571
2582
|
const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
|
|
2572
2583
|
const wr = writingFor(s) // 0.9.5: the one being written, at the top until it lands
|
|
2573
2584
|
const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
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 })
|
|
2577
2592
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
|
|
2578
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>'
|
|
2579
2594
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
2580
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>'
|
|
2581
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
|
|
2582
2601
|
// 0.9.8: the last tools it ran and its folder's changes; loads when you open it
|
|
2583
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))
|
|
2584
2615
|
side += '<section class="fs-sec fs-details" id="fsDetails">' + detailsHtml( // 0.9.11: the facts, behind Details
|
|
2585
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)) +
|
|
2586
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' }) +
|
|
2587
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' }) +
|
|
2588
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>'
|
|
2589
|
-
side += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>' // 0.7.0: steps handed back to you
|
|
2590
|
-
side += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
2591
|
-
side += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
2592
|
-
if (s.lists && s.lists.length) { state.listFull = {}; for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l }
|
|
2593
|
-
state.listsMayEdit = state.mayAnswer !== false
|
|
2594
|
-
side += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
2595
|
-
side += parkRow(s) + spacePicker(s, spv, 'session', s.id)
|
|
2596
|
-
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>'
|
|
2597
|
-
side += answeredList(s)
|
|
2598
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 ⋯
|
|
2599
2621
|
const label = fsPeekWords(s)
|
|
2600
|
-
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(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 })
|
|
2601
2623
|
}
|
|
2602
2624
|
// The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
|
|
2603
2625
|
// "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
|
|
@@ -2606,7 +2628,7 @@ function fsStubHtml(s, inner, foot = '') {
|
|
|
2606
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
|
|
2607
2629
|
}
|
|
2608
2630
|
function fsPeekWords(s) {
|
|
2609
|
-
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
|
|
2610
2632
|
const w = String(LABEL[s.status] || s.status || 'Session')
|
|
2611
2633
|
return w.charAt(0).toUpperCase() + w.slice(1) + ' · ' + n + ' checkpoint' + (n === 1 ? '' : 's')
|
|
2612
2634
|
}
|
|
@@ -2981,7 +3003,24 @@ function fsRailGo(go) {
|
|
|
2981
3003
|
if (el) el.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
2982
3004
|
const d = el && el.querySelector('details:not([open])')
|
|
2983
3005
|
if (d && go === 'fsDetails') d.open = true
|
|
2984
|
-
|
|
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
|
+
|
|
2985
3024
|
function toggleFsSheet() {
|
|
2986
3025
|
const side = document.getElementById('fsSide')
|
|
2987
3026
|
if (!side) return
|
|
@@ -4039,16 +4078,29 @@ async function aroundWire(id) {
|
|
|
4039
4078
|
if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
|
|
4040
4079
|
}
|
|
4041
4080
|
}
|
|
4042
|
-
function cpTimeline(list, newestCp, wtSid = null) {
|
|
4081
|
+
function cpTimeline(list, newestCp, wtSid = null, paths = null) {
|
|
4043
4082
|
return list.map((cp) =>
|
|
4044
|
-
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"
|
|
4045
|
-
(cp
|
|
4046
|
-
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 }) +
|
|
4047
4085
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
4048
|
-
(cp.filesTouched
|
|
4086
|
+
filesTouchedHtml(cp.filesTouched, paths || {}, { esc }) + // 2026-10-29: relative paths, the first five, then +N more
|
|
4049
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
|
|
4050
4088
|
'</div>').join('')
|
|
4051
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
|
+
}
|
|
4052
4104
|
// Space of one session (or, on an agent's page, of that agent's feed): its
|
|
4053
4105
|
// own override, or back to the repo / Mac default (v0.5.8).
|
|
4054
4106
|
function spacePicker(s, spv, kind, key) {
|
|
@@ -4986,6 +5038,8 @@ function anywhereSection(r, adv = []) {
|
|
|
4986
5038
|
if (r.enabled) {
|
|
4987
5039
|
const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
|
|
4988
5040
|
h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
|
|
5041
|
+
// 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
|
|
5042
|
+
h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
|
|
4989
5043
|
if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
|
|
4990
5044
|
// Pairing from a paired phone follows Trust my paired devices (0.6.6).
|
|
4991
5045
|
const mayPair = v.mayPair != null ? !!v.mayPair : local
|
|
@@ -7455,7 +7509,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7455
7509
|
clearTimeout(timer); cur = el
|
|
7456
7510
|
timer = setTimeout(() => {
|
|
7457
7511
|
if (cur !== el || !el.isConnected) return hide()
|
|
7458
|
-
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
|
|
7459
7513
|
tip.textContent = el.dataset.tip
|
|
7460
7514
|
const r = el.getBoundingClientRect(), w = tip.offsetWidth
|
|
7461
7515
|
tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
|
|
@@ -7463,7 +7517,7 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7463
7517
|
tip.classList.add('on')
|
|
7464
7518
|
}, delay)
|
|
7465
7519
|
}
|
|
7466
|
-
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
|
|
7467
7521
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
7468
7522
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
7469
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() })
|
|
@@ -8265,6 +8319,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
8265
8319
|
refreshHealth().catch(() => {})
|
|
8266
8320
|
return render()
|
|
8267
8321
|
}
|
|
8322
|
+
// 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
|
|
8323
|
+
if (action === 'relay-host' || action === 'relay-host-pref') {
|
|
8324
|
+
const machine = btn.dataset.machine || null
|
|
8325
|
+
const prefer = action === 'relay-host-pref'
|
|
8326
|
+
btn.disabled = true
|
|
8327
|
+
let hz
|
|
8328
|
+
try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
8329
|
+
const now = hz && hz.host && hz.host.machine
|
|
8330
|
+
toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
|
|
8331
|
+
if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
|
|
8332
|
+
return render()
|
|
8333
|
+
}
|
|
8268
8334
|
if (action === 'relay-user') {
|
|
8269
8335
|
const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
|
|
8270
8336
|
await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
|
|
@@ -9156,8 +9222,34 @@ async function openToolOutput(sessionId, toolId, label) {
|
|
|
9156
9222
|
// daemon serves only the session's folder and Claude's scratch folders
|
|
9157
9223
|
// (src/file-ref.js). Centered on a desktop, a bottom sheet on a phone.
|
|
9158
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
|
+
}
|
|
9159
9250
|
function openRefFile(btn, cardId) {
|
|
9160
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"
|
|
9161
9253
|
let p = btn.dataset.path || ''
|
|
9162
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)
|
|
9163
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
|
+
}
|