@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.4",
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
- return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : claudeInvocation(session, [...(wt ? ['--worktree', wt] : []), '--resume', id]))
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',
@@ -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.4' // keep in step with .claude-plugin/plugin.json
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.4",
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 = 'Copy resume command' }, 1500)
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="Open it in a new Ghostty tab: ' + (s.codingAgent === 'codex' ? 'codex resume' : 'claude --resume') + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
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>' : '') + ' &nbsp;' + 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.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
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
- b.hidden = !(n > 0)
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 = n > 0 ? ' · ' + n + ' new' : ''
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
- if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1420
- liveKeeper.seen(); liveJumpButton(thread, 0)
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 }
@@ -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).