@leftoff/cli 0.9.8-beta.4 → 0.9.8-beta.5
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 +3 -0
- package/bin/leftoff.js +323 -321
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +12 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +3 -0
- package/ui/app.js +56 -12
- package/ui/live-view.js +8 -0
- package/ui/relay-client.js +56 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.5",
|
|
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": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -299,9 +299,19 @@ export function resumeCommand(session) {
|
|
|
299
299
|
const id = session.id || session.sessionId
|
|
300
300
|
const cwd = resumeDir(session)
|
|
301
301
|
const wt = resumeWorktreeName(session)
|
|
302
|
-
|
|
302
|
+
const args = [...(wt ? ['--worktree', wt] : []), '--resume', id]
|
|
303
|
+
return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : wasLiveSession(session) ? liveInvocation(args) : claudeInvocation(session, args))
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
// 0.9.8: a session started with `leftoff live` (it had Leftoff's Live channel)
|
|
307
|
+
// resumes as one, `leftoff live -- --resume <id>`, so it shows up Live again.
|
|
308
|
+
// Codex and Ollama sessions resume their own way (Live is Claude Code only).
|
|
309
|
+
export function wasLiveSession(session) {
|
|
310
|
+
if (!session || isCodex(session) || (session.backend && session.backend.kind === 'ollama')) return false
|
|
311
|
+
return !!(session.wasLive || (session.liveLink && session.liveLink.on))
|
|
312
|
+
}
|
|
313
|
+
export function liveInvocation(args = []) { return 'leftoff live --' + (args.length ? ' ' + args.map(shArg).join(' ') : '') }
|
|
314
|
+
|
|
305
315
|
// 0.8.0: a Codex session (its rollout under ~/.codex/sessions) resumes with `codex resume <id>`.
|
|
306
316
|
export function isCodex(session) { return !!session && session.codingAgent === 'codex' }
|
|
307
317
|
export function codexInvocation(id) { return 'codex resume ' + shArg(id) }
|
|
@@ -547,7 +557,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
547
557
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
548
558
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
549
559
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
550
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns']
|
|
560
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
|
|
551
561
|
|
|
552
562
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
553
563
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
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.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.5' // 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.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.5",
|
|
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
|
@@ -2114,6 +2114,9 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2114
2114
|
.live-jump-n { font-weight: 500; color: var(--muted); }
|
|
2115
2115
|
.live-empty { margin: 6px 0 10px; font-size: 13px; }
|
|
2116
2116
|
.live-err { color: var(--red); font-size: 13px; }
|
|
2117
|
+
/* 0.9.8: another Mac's session whose Mac is offline */
|
|
2118
|
+
.owner-off { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--text); font-size: 14px; line-height: 1.45; }
|
|
2119
|
+
.owner-off .ic { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--muted); }
|
|
2117
2120
|
.live-row .live-text { min-height: 44px; }
|
|
2118
2121
|
/* Chat on a live card: straight to the Live box */
|
|
2119
2122
|
.btn.chat-btn { position: relative; color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--btn-line)); font-weight: 600; }
|
package/ui/app.js
CHANGED
|
@@ -19,7 +19,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
19
19
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
20
|
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
21
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
22
|
-
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
|
|
22
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView } from './live-view.js' // 0.9.8 Live link polish
|
|
23
23
|
import { statusView, elapsedText } from './live-status.js'
|
|
24
24
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
25
25
|
const $app = document.getElementById('app')
|
|
@@ -113,8 +113,9 @@ async function copy(text) {
|
|
|
113
113
|
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
114
114
|
function copiedMark(btn) {
|
|
115
115
|
clearTimeout(btn._copied)
|
|
116
|
+
const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
|
|
116
117
|
btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
|
|
117
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title =
|
|
118
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
118
119
|
}
|
|
119
120
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
120
121
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
@@ -443,8 +444,12 @@ function agentCard(s) {
|
|
|
443
444
|
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
444
445
|
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
445
446
|
// same data-action as before), so the one click handler below runs them unchanged.
|
|
447
|
+
// What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
|
|
448
|
+
// leftoff live -- --resume, so it comes back Live.
|
|
449
|
+
function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
|
|
450
|
+
function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
|
|
446
451
|
function copyResumeIcon(s) {
|
|
447
|
-
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
452
|
+
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + '</button>'
|
|
448
453
|
}
|
|
449
454
|
// items: [{ action, label, icon, title, attrs }]
|
|
450
455
|
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
@@ -932,10 +937,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
932
937
|
const busy = closedUi.busy.has(s.id)
|
|
933
938
|
if (s.isLocal) {
|
|
934
939
|
if (!viewer.ghostty) return ''
|
|
935
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="
|
|
940
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab')) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
|
|
936
941
|
}
|
|
937
942
|
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
938
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
|
|
943
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab on ' + s.machine)) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
|
|
939
944
|
}
|
|
940
945
|
function closedCard(s, viewer) {
|
|
941
946
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1274,12 +1279,28 @@ async function viewAgentSession(s) {
|
|
|
1274
1279
|
$app.innerHTML = banners() + html
|
|
1275
1280
|
}
|
|
1276
1281
|
|
|
1282
|
+
// 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
|
|
1283
|
+
function ownerOffline(s) {
|
|
1284
|
+
if (!s || s.isLocal !== false || s.agent || !s.machine) return false
|
|
1285
|
+
const st = window.leftoffRelay && state.relayStatus
|
|
1286
|
+
const x = st && (st.macs || []).find((y) => y.machine === s.machine)
|
|
1287
|
+
if (x) return !x.online
|
|
1288
|
+
return !!(s.peer && s.peer.code === 'unreachable')
|
|
1289
|
+
}
|
|
1290
|
+
function ownerOfflineNote(s) {
|
|
1291
|
+
if (!ownerOffline(s)) return ''
|
|
1292
|
+
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1293
|
+
return '<p class="owner-off" role="status">' + icon('wifi-off') + '<span>This session lives on <b>' + m + '</b>, which is offline right now. You’re seeing what it last synced' + (sn ? ' (' + esc(sn) + ')' : '') + '. Live, its transcript and Find lists come back when ' + m + ' is.</span></p>'
|
|
1294
|
+
}
|
|
1277
1295
|
async function viewSession(id) {
|
|
1278
1296
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1279
1297
|
const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1280
1298
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1299
|
+
// 0.9.8 auto-switch host: on leftoff.dev, another Mac's session loads its own data from that Mac
|
|
1300
|
+
if (window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(s.id, s.isLocal === false && !s.agent ? s.machine : null)
|
|
1281
1301
|
const branch = s.repo && s.repo.branch
|
|
1282
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()
|
|
1302
|
+
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects(),
|
|
1303
|
+
s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null])
|
|
1283
1304
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1284
1305
|
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1285
1306
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
@@ -1287,7 +1308,8 @@ async function viewSession(id) {
|
|
|
1287
1308
|
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1288
1309
|
: '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>') + '<div class="sub">' + statusPill(s) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
|
|
1289
1310
|
// 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
|
|
1290
|
-
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="Copy resume command">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1311
|
+
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1312
|
+
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1291
1313
|
html += '<div class="facts">' +
|
|
1292
1314
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1293
1315
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
@@ -1392,8 +1414,16 @@ function liveAfterRender(sid) {
|
|
|
1392
1414
|
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1393
1415
|
if (ol && !ol.dataset.wired) {
|
|
1394
1416
|
ol.dataset.wired = '1'
|
|
1395
|
-
ol.
|
|
1417
|
+
ol._stick = atBottom(ol)
|
|
1418
|
+
// the button follows the scroll: there whenever you're away from the bottom, gone once you're back
|
|
1419
|
+
ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
|
|
1420
|
+
// at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
|
|
1421
|
+
if (typeof ResizeObserver === 'function') {
|
|
1422
|
+
ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
|
|
1423
|
+
}
|
|
1396
1424
|
}
|
|
1425
|
+
if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
|
|
1426
|
+
if (ol && r.follow) ol._stick = true
|
|
1397
1427
|
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1398
1428
|
const want = parseLiveRoute(location.hash)
|
|
1399
1429
|
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
@@ -1406,18 +1436,32 @@ function liveAfterRender(sid) {
|
|
|
1406
1436
|
}
|
|
1407
1437
|
liveTick()
|
|
1408
1438
|
}
|
|
1439
|
+
// "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
|
|
1440
|
+
// count of what came in since, when there is any), and hides once you're back at the bottom.
|
|
1409
1441
|
function liveJumpButton(thread, n) {
|
|
1410
1442
|
const b = thread && thread.querySelector('.live-jump')
|
|
1411
1443
|
if (!b) return
|
|
1412
|
-
|
|
1444
|
+
const ol = thread.querySelector('.live-log')
|
|
1445
|
+
const v = jumpView(ol, n)
|
|
1446
|
+
if (!v.show && n > 0) liveKeeper.seen()
|
|
1447
|
+
// on its way down after a tap: don't flash back up mid-scroll
|
|
1448
|
+
if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
|
|
1449
|
+
b.hidden = !v.show
|
|
1413
1450
|
const c = b.querySelector('.live-jump-n')
|
|
1414
|
-
if (c) c.textContent =
|
|
1451
|
+
if (c) c.textContent = v.count
|
|
1452
|
+
b.setAttribute('aria-label', v.label)
|
|
1415
1453
|
}
|
|
1416
1454
|
function liveJumpClick(btn) {
|
|
1417
1455
|
const thread = btn.closest('.live-thread')
|
|
1418
1456
|
const ol = thread && thread.querySelector('.live-log')
|
|
1419
|
-
|
|
1420
|
-
|
|
1457
|
+
const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
1458
|
+
if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
|
|
1459
|
+
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
|
1460
|
+
liveKeeper.seen()
|
|
1461
|
+
btn.hidden = true
|
|
1462
|
+
const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
|
|
1463
|
+
// land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
|
|
1464
|
+
setTimeout(() => { if (!thread || !thread.isConnected) return; delete thread.dataset.jumping; if (ol && !atBottom(ol, 4)) ol.scrollTop = ol.scrollHeight; liveJumpButton(thread, liveKeeper.unseen) }, smooth ? 950 : 0)
|
|
1421
1465
|
}
|
|
1422
1466
|
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1423
1467
|
async function liveEarlierClick(btn) {
|
package/ui/live-view.js
CHANGED
|
@@ -99,6 +99,14 @@ export function senderTitle(m) {
|
|
|
99
99
|
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
100
100
|
return scrollHeight - clientHeight - scrollTop <= slack
|
|
101
101
|
}
|
|
102
|
+
// "Jump to latest ↓": shown whenever a thread that scrolls is away from its bottom, with the
|
|
103
|
+
// count of what came in since when there is any; at the bottom it's hidden and nothing is unseen.
|
|
104
|
+
export function jumpView(box = {}, unseen = 0) {
|
|
105
|
+
const { scrollHeight = 0, clientHeight = 0 } = box || {}
|
|
106
|
+
const away = scrollHeight > clientHeight + 1 && !atBottom(box)
|
|
107
|
+
const n = away && unseen > 0 ? unseen : 0
|
|
108
|
+
return { show: away, unseen: n, count: n > 0 ? ' · ' + n + ' new' : '', label: 'Jump to latest message' + (n > 0 ? ' (' + n + ' new)' : '') }
|
|
109
|
+
}
|
|
102
110
|
// After new content: follow it, or leave the view where it is and count what is new.
|
|
103
111
|
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
104
112
|
if (wasAtBottom) return { follow: true, unseen: 0 }
|
package/ui/relay-client.js
CHANGED
|
@@ -44,7 +44,7 @@ export async function forgetDeviceKey() {
|
|
|
44
44
|
|
|
45
45
|
// ------------------------------------------------------------ state
|
|
46
46
|
|
|
47
|
-
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
|
|
47
|
+
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null, owners: new Map() }
|
|
48
48
|
// ui/direct.js loads only on the board itself (never on pair.html, which the
|
|
49
49
|
// relay serves before a browser is paired and where this file is imported too).
|
|
50
50
|
let route = null, directOrRelay = null
|
|
@@ -140,6 +140,39 @@ function watchDirect() {
|
|
|
140
140
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') again(true) })
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ------------------------------------------------------------ auto-switch host (0.9.8)
|
|
144
|
+
// On your leftoff.dev board, a session that lives on another Mac loads its own
|
|
145
|
+
// data (Live, its transcript, Find lists, files...) straight from that Mac
|
|
146
|
+
// through the relay, instead of from the Mac serving the board. The board says
|
|
147
|
+
// which Mac each session lives on (leftoffRelay.owner); these decide where one
|
|
148
|
+
// call goes. Pure and exported, so they are unit-tested.
|
|
149
|
+
|
|
150
|
+
// /v1/sessions/<id>/<anything> -> <id>; the record itself (/v1/sessions/<id>) stays on
|
|
151
|
+
// the board's Mac (it's the synced copy the board lists), and so does everything else.
|
|
152
|
+
const SESSION_SUB = /^\/v1\/sessions\/([^/?#]+)\/[^?#]+$/
|
|
153
|
+
export function sessionOfPath(pathname) {
|
|
154
|
+
const m = SESSION_SUB.exec(String(pathname || ''))
|
|
155
|
+
if (!m) return null
|
|
156
|
+
try { return decodeURIComponent(m[1]) } catch { return null }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Which Mac a call should go to, or null for the board's own Mac.
|
|
160
|
+
// owners: Map(sessionId -> machine) the board filled in; current: the Mac serving the board;
|
|
161
|
+
// online: the Macs the relay says are connected now.
|
|
162
|
+
export function ownerRoute(pathname, owners, { current = null, online = [] } = {}) {
|
|
163
|
+
const id = sessionOfPath(pathname)
|
|
164
|
+
if (!id || !owners || typeof owners.get !== 'function') return null
|
|
165
|
+
const m = owners.get(id)
|
|
166
|
+
if (!m || m === current) return null
|
|
167
|
+
return online.includes(m) ? m : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A failed hop to the owner falls back to the board's Mac (which reaches the
|
|
171
|
+
// owner over the peer link, or says plainly that it's offline).
|
|
172
|
+
export function fallBack({ status = 0, code = '' } = {}) {
|
|
173
|
+
return status === 0 || status === 401 || status === 403 && code === 'unknown_device' || status === 502 || status === 503 || status === 504 || code === 'mac_offline' || code === 'timeout'
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
// ------------------------------------------------------------ the tunnel
|
|
144
177
|
|
|
145
178
|
async function tunnel(url, init = {}, retried = false) {
|
|
@@ -151,6 +184,9 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
151
184
|
const hdrs = new Headers(init.headers || {})
|
|
152
185
|
if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
|
|
153
186
|
const body = { m: method, p: url.pathname + url.search, b, h }
|
|
187
|
+
// 0.9.8: a session that lives on another Mac answers for itself (ui/owner-route.js)
|
|
188
|
+
const owner = ownerRoute(url.pathname, relay.owners, { current: relay.mac, online: onlineMacs() })
|
|
189
|
+
if (owner) { const r = await viaOwner(owner, body, init).catch(() => null); if (r) return r }
|
|
154
190
|
if (!onDirect()) return viaRelay(body, init, retried)
|
|
155
191
|
return directOrRelay({
|
|
156
192
|
route,
|
|
@@ -164,6 +200,22 @@ async function tunnel(url, init = {}, retried = false) {
|
|
|
164
200
|
})
|
|
165
201
|
}
|
|
166
202
|
|
|
203
|
+
function onlineMacs() { return ((relay.status && relay.status.macs) || []).filter((m) => m.online).map((m) => m.machine) }
|
|
204
|
+
// Straight to the session's own Mac through the relay; null sends the call to the
|
|
205
|
+
// board's Mac instead (the owner went away, or doesn't know this browser yet).
|
|
206
|
+
async function viaOwner(machine, body, init) {
|
|
207
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine, body })
|
|
208
|
+
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
209
|
+
if (res.status !== 200) {
|
|
210
|
+
const j = await res.json().catch(() => ({}))
|
|
211
|
+
if (j.code === 'mac_offline') refreshStatus().catch(() => {})
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
215
|
+
if (out.status === 401 || out.status === 403) { const j = await out.clone().json().catch(() => ({})); if (fallBack({ status: out.status, code: j.code || '' }) || /device/.test(j.code || '')) return null }
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
167
219
|
async function viaRelay(body, init, retried) {
|
|
168
220
|
const gone = relay.mac
|
|
169
221
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
@@ -272,6 +324,9 @@ export const leftoffRelay = {
|
|
|
272
324
|
get route() { return onDirect() ? 'direct' : 'relay' },
|
|
273
325
|
get directLatencyMs() { return route ? route.latencyMs : null },
|
|
274
326
|
status: () => refreshStatus(),
|
|
327
|
+
// 0.9.8: which Mac a session lives on (null: the board's own), so its calls go there
|
|
328
|
+
owner(id, machine) { if (!id) return; if (machine) relay.owners.set(String(id), String(machine)); else relay.owners.delete(String(id)) },
|
|
329
|
+
ownerOf(id) { const m = relay.owners.get(String(id)); return m && m !== relay.mac && onlineMacs().includes(m) ? m : null },
|
|
275
330
|
// Switch Macs: the board reloads from the other one.
|
|
276
331
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
277
332
|
// This browser revoked itself (0.6.6).
|