@leftoff/cli 0.9.8-beta.4 → 0.9.8-beta.6
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 +4 -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 +10 -0
- package/ui/app.js +60 -13
- package/ui/live-view.js +46 -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.6",
|
|
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.6' // 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.6",
|
|
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
|
@@ -2038,6 +2038,13 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2038
2038
|
|
|
2039
2039
|
/* 0.9.8 Live link: the Live badge, and the session page's message box with its delivery states */
|
|
2040
2040
|
.pill.live-pill { color: var(--green); background: var(--tint-green); font-weight: 600; } /* not .live: that class is the open/closed dot */
|
|
2041
|
+
/* Its dot pulses while Claude is running a turn (a soft ring growing out of it); idle, it stays still. The text doesn't blink. */
|
|
2042
|
+
.pill.live-pill.working::before { animation: live-pulse 1.6s ease-in-out infinite; }
|
|
2043
|
+
@keyframes live-pulse {
|
|
2044
|
+
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 55%, transparent); }
|
|
2045
|
+
80%, 100% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--green) 0%, transparent); }
|
|
2046
|
+
}
|
|
2047
|
+
@media (prefers-reduced-motion: reduce) { .pill.live-pill.working::before, .s-active::before { animation: none; } }
|
|
2041
2048
|
.live-box { margin: 16px 0 18px; border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line)); background: linear-gradient(180deg, var(--tint-green), transparent 70%), var(--card); border-radius: var(--radius); padding: 12px 14px 14px; }
|
|
2042
2049
|
.live-box.off { border-color: var(--line); background: var(--card); }
|
|
2043
2050
|
.live-box.off p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
|
|
@@ -2114,6 +2121,9 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2114
2121
|
.live-jump-n { font-weight: 500; color: var(--muted); }
|
|
2115
2122
|
.live-empty { margin: 6px 0 10px; font-size: 13px; }
|
|
2116
2123
|
.live-err { color: var(--red); font-size: 13px; }
|
|
2124
|
+
/* 0.9.8: another Mac's session whose Mac is offline */
|
|
2125
|
+
.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; }
|
|
2126
|
+
.owner-off .ic { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--muted); }
|
|
2117
2127
|
.live-row .live-text { min-height: 44px; }
|
|
2118
2128
|
/* Chat on a live card: straight to the Live box */
|
|
2119
2129
|
.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,10 +19,12 @@ 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, liveBadgeHtml, lockPulses } 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')
|
|
26
|
+
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
27
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
26
28
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
27
29
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
28
30
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
@@ -113,12 +115,14 @@ async function copy(text) {
|
|
|
113
115
|
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
114
116
|
function copiedMark(btn) {
|
|
115
117
|
clearTimeout(btn._copied)
|
|
118
|
+
const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
|
|
116
119
|
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 =
|
|
120
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
118
121
|
}
|
|
119
122
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
120
123
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
121
|
-
|
|
124
|
+
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
125
|
+
const liveBadge = (s) => liveBadgeHtml(s)
|
|
122
126
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
123
127
|
const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
|
|
124
128
|
|
|
@@ -443,8 +447,12 @@ function agentCard(s) {
|
|
|
443
447
|
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
444
448
|
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
445
449
|
// same data-action as before), so the one click handler below runs them unchanged.
|
|
450
|
+
// What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
|
|
451
|
+
// leftoff live -- --resume, so it comes back Live.
|
|
452
|
+
function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
|
|
453
|
+
function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
|
|
446
454
|
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>'
|
|
455
|
+
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
456
|
}
|
|
449
457
|
// items: [{ action, label, icon, title, attrs }]
|
|
450
458
|
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
@@ -932,10 +940,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
932
940
|
const busy = closedUi.busy.has(s.id)
|
|
933
941
|
if (s.isLocal) {
|
|
934
942
|
if (!viewer.ghostty) return ''
|
|
935
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="
|
|
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')) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
|
|
936
944
|
}
|
|
937
945
|
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>'
|
|
946
|
+
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
947
|
}
|
|
940
948
|
function closedCard(s, viewer) {
|
|
941
949
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1274,12 +1282,28 @@ async function viewAgentSession(s) {
|
|
|
1274
1282
|
$app.innerHTML = banners() + html
|
|
1275
1283
|
}
|
|
1276
1284
|
|
|
1285
|
+
// 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
|
|
1286
|
+
function ownerOffline(s) {
|
|
1287
|
+
if (!s || s.isLocal !== false || s.agent || !s.machine) return false
|
|
1288
|
+
const st = window.leftoffRelay && state.relayStatus
|
|
1289
|
+
const x = st && (st.macs || []).find((y) => y.machine === s.machine)
|
|
1290
|
+
if (x) return !x.online
|
|
1291
|
+
return !!(s.peer && s.peer.code === 'unreachable')
|
|
1292
|
+
}
|
|
1293
|
+
function ownerOfflineNote(s) {
|
|
1294
|
+
if (!ownerOffline(s)) return ''
|
|
1295
|
+
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1296
|
+
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>'
|
|
1297
|
+
}
|
|
1277
1298
|
async function viewSession(id) {
|
|
1278
1299
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1279
1300
|
const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1280
1301
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1302
|
+
// 0.9.8 auto-switch host: on leftoff.dev, another Mac's session loads its own data from that Mac
|
|
1303
|
+
if (window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(s.id, s.isLocal === false && !s.agent ? s.machine : null)
|
|
1281
1304
|
const branch = s.repo && s.repo.branch
|
|
1282
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()
|
|
1305
|
+
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects(),
|
|
1306
|
+
s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null])
|
|
1283
1307
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1284
1308
|
// 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
1309
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
@@ -1287,7 +1311,8 @@ async function viewSession(id) {
|
|
|
1287
1311
|
? '<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
1312
|
: '<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
1313
|
// 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>'
|
|
1314
|
+
'<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>'
|
|
1315
|
+
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1291
1316
|
html += '<div class="facts">' +
|
|
1292
1317
|
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
1318
|
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 +1417,16 @@ function liveAfterRender(sid) {
|
|
|
1392
1417
|
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1393
1418
|
if (ol && !ol.dataset.wired) {
|
|
1394
1419
|
ol.dataset.wired = '1'
|
|
1395
|
-
ol.
|
|
1420
|
+
ol._stick = atBottom(ol)
|
|
1421
|
+
// the button follows the scroll: there whenever you're away from the bottom, gone once you're back
|
|
1422
|
+
ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
|
|
1423
|
+
// at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
|
|
1424
|
+
if (typeof ResizeObserver === 'function') {
|
|
1425
|
+
ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
|
|
1426
|
+
}
|
|
1396
1427
|
}
|
|
1428
|
+
if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
|
|
1429
|
+
if (ol && r.follow) ol._stick = true
|
|
1397
1430
|
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1398
1431
|
const want = parseLiveRoute(location.hash)
|
|
1399
1432
|
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
@@ -1406,18 +1439,32 @@ function liveAfterRender(sid) {
|
|
|
1406
1439
|
}
|
|
1407
1440
|
liveTick()
|
|
1408
1441
|
}
|
|
1442
|
+
// "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
|
|
1443
|
+
// count of what came in since, when there is any), and hides once you're back at the bottom.
|
|
1409
1444
|
function liveJumpButton(thread, n) {
|
|
1410
1445
|
const b = thread && thread.querySelector('.live-jump')
|
|
1411
1446
|
if (!b) return
|
|
1412
|
-
|
|
1447
|
+
const ol = thread.querySelector('.live-log')
|
|
1448
|
+
const v = jumpView(ol, n)
|
|
1449
|
+
if (!v.show && n > 0) liveKeeper.seen()
|
|
1450
|
+
// on its way down after a tap: don't flash back up mid-scroll
|
|
1451
|
+
if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
|
|
1452
|
+
b.hidden = !v.show
|
|
1413
1453
|
const c = b.querySelector('.live-jump-n')
|
|
1414
|
-
if (c) c.textContent =
|
|
1454
|
+
if (c) c.textContent = v.count
|
|
1455
|
+
b.setAttribute('aria-label', v.label)
|
|
1415
1456
|
}
|
|
1416
1457
|
function liveJumpClick(btn) {
|
|
1417
1458
|
const thread = btn.closest('.live-thread')
|
|
1418
1459
|
const ol = thread && thread.querySelector('.live-log')
|
|
1419
|
-
|
|
1420
|
-
|
|
1460
|
+
const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
1461
|
+
if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
|
|
1462
|
+
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
|
1463
|
+
liveKeeper.seen()
|
|
1464
|
+
btn.hidden = true
|
|
1465
|
+
const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
|
|
1466
|
+
// land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
|
|
1467
|
+
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
1468
|
}
|
|
1422
1469
|
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1423
1470
|
async function liveEarlierClick(btn) {
|
package/ui/live-view.js
CHANGED
|
@@ -65,6 +65,44 @@ export function cardPills(s) {
|
|
|
65
65
|
return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Is Claude running a turn in this live session right now? The Live pill's dot
|
|
69
|
+
// pulses while it is. The same signals the board already shows: the session's
|
|
70
|
+
// own status (a turn typed in the terminal, which the hooks see start), or the
|
|
71
|
+
// board message whose turn is still going, read the way its Working bubble
|
|
72
|
+
// reads it (spinning: sent, received or working; not waiting for your OK, not
|
|
73
|
+
// gone quiet). Idle, waiting on you, or another Mac's daemon that doesn't say:
|
|
74
|
+
// a steady dot.
|
|
75
|
+
export function liveWorking(s, now = Date.now()) {
|
|
76
|
+
if (!cardPills(s).live) return false
|
|
77
|
+
if (s.status === 'active') return true
|
|
78
|
+
const t = s.liveLink && s.liveLink.turn
|
|
79
|
+
if (!t || !t.phase) return false
|
|
80
|
+
const v = statusView(t, now)
|
|
81
|
+
return !!(v && v.spin)
|
|
82
|
+
}
|
|
83
|
+
const LIVE_TIP = 'Live link: send this session messages from the board or your phone'
|
|
84
|
+
export function liveBadgeHtml(s, now = Date.now()) {
|
|
85
|
+
if (!cardPills(s).live) return ''
|
|
86
|
+
const w = liveWorking(s, now)
|
|
87
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
91
|
+
// CSS pulse each time: a visible hitch every second or so while a session
|
|
92
|
+
// works. Pinning every pulse to the page's own clock (start time 0) makes a
|
|
93
|
+
// fresh element pick up exactly where the one it replaced was, and keeps the
|
|
94
|
+
// dots on the page beating together. Reduced motion has no animations to pin.
|
|
95
|
+
export const PULSE_NAMES = new Set(['live-pulse', 'pulse', 'needpulse'])
|
|
96
|
+
export function lockPulses(doc) {
|
|
97
|
+
if (!doc || typeof doc.getAnimations !== 'function') return 0
|
|
98
|
+
let n = 0
|
|
99
|
+
for (const a of doc.getAnimations()) {
|
|
100
|
+
if (!PULSE_NAMES.has(a.animationName) || a.startTime === 0) continue
|
|
101
|
+
try { a.startTime = 0; n++ } catch {}
|
|
102
|
+
}
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
// The "N empty sessions" fold never takes a live session.
|
|
69
107
|
export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
|
|
70
108
|
|
|
@@ -99,6 +137,14 @@ export function senderTitle(m) {
|
|
|
99
137
|
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
100
138
|
return scrollHeight - clientHeight - scrollTop <= slack
|
|
101
139
|
}
|
|
140
|
+
// "Jump to latest ↓": shown whenever a thread that scrolls is away from its bottom, with the
|
|
141
|
+
// count of what came in since when there is any; at the bottom it's hidden and nothing is unseen.
|
|
142
|
+
export function jumpView(box = {}, unseen = 0) {
|
|
143
|
+
const { scrollHeight = 0, clientHeight = 0 } = box || {}
|
|
144
|
+
const away = scrollHeight > clientHeight + 1 && !atBottom(box)
|
|
145
|
+
const n = away && unseen > 0 ? unseen : 0
|
|
146
|
+
return { show: away, unseen: n, count: n > 0 ? ' · ' + n + ' new' : '', label: 'Jump to latest message' + (n > 0 ? ' (' + n + ' new)' : '') }
|
|
147
|
+
}
|
|
102
148
|
// After new content: follow it, or leave the view where it is and count what is new.
|
|
103
149
|
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
104
150
|
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).
|