@leftoff/cli 0.9.8-beta.3 → 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 +5 -0
- package/bin/leftoff.js +334 -332
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +47 -1
- package/ui/app.js +72 -23
- package/ui/live-view.js +41 -1
- 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']
|
|
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',
|
|
@@ -800,7 +810,7 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
|
|
|
800
810
|
export function isEmptySession(s, checkpointCount = 0) {
|
|
801
811
|
if (!s) return true
|
|
802
812
|
// 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
|
|
803
|
-
return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
813
|
+
return !(Number(s.turns) > 0) && !(Number(s.transcriptTurns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
804
814
|
!(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
|
|
805
815
|
}
|
|
806
816
|
|
|
@@ -876,7 +886,7 @@ export function applyEvent(s, ev) {
|
|
|
876
886
|
s.needsInput = false
|
|
877
887
|
s.question = null
|
|
878
888
|
s.lastNotification = null
|
|
879
|
-
if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]
|
|
889
|
+
if (d.prompt && (!s.firstPrompt || /^\s*<channel\b/.test(s.firstPrompt))) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]*>/, '').replace(/<\/channel>\s*$/, '').trim(), 300) || s.firstPrompt || null // 0.9.8: a Live link message, without its wrapper (even one the 300-character clip cut short)
|
|
880
890
|
if (d.countTurn === true) s.turns = (s.turns || 0) + 1 // 0.8.1: Codex counts a turn at its prompt when no task_started came first
|
|
881
891
|
if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
|
|
882
892
|
}
|
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
|
@@ -120,7 +120,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
120
120
|
.card:hover { border-color: var(--line-strong); }
|
|
121
121
|
.card.needs_input { border-color: color-mix(in srgb, var(--red) 45%, transparent); box-shadow: 0 0 0 1px var(--tint-red) inset, var(--card-shadow); }
|
|
122
122
|
.card .row1 { display: flex; align-items: flex-start; gap: 10px; }
|
|
123
|
-
.card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; }
|
|
123
|
+
.card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; min-width: 0; overflow-wrap: break-word; } /* breaks at the <wbr> after a "/" and at spaces first; inside a word only when one word is wider than the card */
|
|
124
124
|
.card .title a:hover { text-decoration: underline; text-decoration-color: var(--faint); }
|
|
125
125
|
.s-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
126
126
|
.s-title h1 { min-width: 0; }
|
|
@@ -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; }
|
|
@@ -2260,3 +2263,46 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2260
2263
|
@media (pointer: coarse) and (max-width: 819.98px) { html, body { overscroll-behavior-y: contain; } }
|
|
2261
2264
|
@media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
|
|
2262
2265
|
@keyframes lo-pulse { 50% { opacity: .35; } }
|
|
2266
|
+
|
|
2267
|
+
/* 0.9.8: the session page's header. The title takes the room that's left and
|
|
2268
|
+
breaks at " · " and after "/" (titleHtml's <wbr>), never mid-word unless one
|
|
2269
|
+
word is wider than the page; the buttons are one group that drops under the
|
|
2270
|
+
title as a whole when it doesn't fit beside it. Every button: one line, 44px,
|
|
2271
|
+
an icon and a short label; the primary ones (Checkpoint, Resume) are tinted,
|
|
2272
|
+
not filled, so they don't shout over the rest. */
|
|
2273
|
+
.pagehead.s-head { flex-wrap: wrap; align-items: flex-start; column-gap: 20px; row-gap: 12px; }
|
|
2274
|
+
.pagehead.s-head > .s-head-main { flex: 1 1 28rem; min-width: 0; } /* under ~28rem beside the buttons, the buttons move down instead */
|
|
2275
|
+
.s-head h1 { overflow-wrap: break-word; line-height: 1.3; }
|
|
2276
|
+
.pagehead.s-head > .s-actions { margin-left: 0; flex: 0 1 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; }
|
|
2277
|
+
.s-actions .btn { height: 44px; min-height: 44px; padding: 0 14px; gap: 7px; font-size: 13.5px; line-height: 1; white-space: nowrap; flex: none; justify-content: center; }
|
|
2278
|
+
.s-actions .btn .ic { width: 16px; height: 16px; flex: none; margin: 0; }
|
|
2279
|
+
.s-actions .btn.icon-btn { width: 44px; padding: 0; }
|
|
2280
|
+
.s-actions .more-wrap { display: inline-flex; }
|
|
2281
|
+
.s-actions .btn.primary { background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--btn-line)); }
|
|
2282
|
+
.s-actions .btn.primary:hover { filter: none; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: var(--accent); }
|
|
2283
|
+
.s-actions .btn.primary:active { filter: none; background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
2284
|
+
.s-actions .split { flex: none; display: inline-flex; align-items: stretch; height: 44px; }
|
|
2285
|
+
.s-actions .split .btn { height: 44px; }
|
|
2286
|
+
.s-actions .split .btn.split-more { width: 38px; min-width: 38px; padding: 0; margin-left: 0; border-left: 1px solid color-mix(in srgb, var(--accent) 30%, var(--btn-line)); }
|
|
2287
|
+
.s-actions .split .btn.split-main { border-right-width: 0; }
|
|
2288
|
+
.s-actions .split.is-writing .btn:disabled { opacity: .85; border-style: solid; background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--btn-line)); }
|
|
2289
|
+
.s-actions .split.is-writing .spin { border-color: color-mix(in srgb, var(--accent) 30%, transparent); border-top-color: var(--accent); }
|
|
2290
|
+
@media (max-width: 640px) {
|
|
2291
|
+
/* a phone: the buttons are a row under the title that wraps, each part of a row sharing its width */
|
|
2292
|
+
.pagehead.s-head > .s-actions { flex: 1 1 100%; }
|
|
2293
|
+
.s-actions > .btn, .s-actions > .split { flex: 1 1 auto; }
|
|
2294
|
+
.s-actions .split .split-main { flex: 1 1 auto; }
|
|
2295
|
+
.s-actions > .more-wrap { flex: none; }
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/* 0.9.8: a card's row (Go, Chat, copy, ⋯) and Catch me up's row: one line each,
|
|
2299
|
+
all the same height (44px on touch). */
|
|
2300
|
+
.card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { white-space: nowrap; height: 30px; min-height: 30px; padding-top: 0; padding-bottom: 0; line-height: 28px; }
|
|
2301
|
+
.card > .actions-row > .btn.icon-btn { width: 32px; line-height: 1; }
|
|
2302
|
+
.card > .actions-row > .btn.chat-btn { min-width: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding-top: 0; padding-bottom: 0; overflow: visible; }
|
|
2303
|
+
.cu-s .actions-row > .btn { display: inline-flex; align-items: center; }
|
|
2304
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2305
|
+
.card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { height: 44px; min-height: 44px; line-height: 42px; }
|
|
2306
|
+
.card > .actions-row > .btn.icon-btn { width: 44px; }
|
|
2307
|
+
.card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
|
|
2308
|
+
}
|
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, 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)
|
|
@@ -184,11 +185,11 @@ function wtChips(s) {
|
|
|
184
185
|
return h
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
function where(s) {
|
|
188
|
+
function where(s, { repo = true } = {}) {
|
|
188
189
|
if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
|
|
189
190
|
const branch = s.repo && s.repo.branch
|
|
190
191
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
191
|
-
return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
192
|
+
return projChip(s) + (repo ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' : '') + wtChips(s) +
|
|
192
193
|
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -387,9 +388,13 @@ function liveNote(s) {
|
|
|
387
388
|
if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
|
|
388
389
|
return s.live === 'open' ? 'open' : ''
|
|
389
390
|
}
|
|
390
|
-
|
|
391
|
+
// 0.9.8: { page: true } is the session page's header, where a Go that can't run
|
|
392
|
+
// is left out (its tooltip was the only place the reason showed, and the grey
|
|
393
|
+
// box read as broken); a card keeps the dimmed Go and its short why.
|
|
394
|
+
function goButton(s, { page = false } = {}) {
|
|
391
395
|
if (s.live === 'closed') return ''
|
|
392
396
|
const j = s.jump || {}
|
|
397
|
+
if (page && (state.viewerLocal === false || !j.supported)) return ''
|
|
393
398
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
394
399
|
if (!s.isLocal) {
|
|
395
400
|
if (!j.peer) return ''
|
|
@@ -439,8 +444,12 @@ function agentCard(s) {
|
|
|
439
444
|
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
440
445
|
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
441
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)' : '') }
|
|
442
451
|
function copyResumeIcon(s) {
|
|
443
|
-
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>'
|
|
444
453
|
}
|
|
445
454
|
// items: [{ action, label, icon, title, attrs }]
|
|
446
455
|
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
@@ -557,7 +566,7 @@ function checkpointSplit(s) {
|
|
|
557
566
|
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
558
567
|
}
|
|
559
568
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
560
|
-
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '"
|
|
569
|
+
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '" aria-label="Checkpoint now">' + icon('bookmark-plus') + 'Checkpoint</button>' +
|
|
561
570
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
562
571
|
}
|
|
563
572
|
function openLengthMenu(btn, id) {
|
|
@@ -581,10 +590,10 @@ function card(s) {
|
|
|
581
590
|
const nb = s.needsYou ? cardBadge(s.needsYou) : ''
|
|
582
591
|
return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
|
|
583
592
|
'<div class="row1"><div class="title">' + liveDot(s) +
|
|
584
|
-
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' +
|
|
593
|
+
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
585
594
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
586
595
|
'</div>' + statusPill(s) + liveBadge(s) + '</div>' +
|
|
587
|
-
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
596
|
+
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
588
597
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
589
598
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
590
599
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
@@ -928,10 +937,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
928
937
|
const busy = closedUi.busy.has(s.id)
|
|
929
938
|
if (s.isLocal) {
|
|
930
939
|
if (!viewer.ghostty) return ''
|
|
931
|
-
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>'
|
|
932
941
|
}
|
|
933
942
|
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
934
|
-
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>'
|
|
935
944
|
}
|
|
936
945
|
function closedCard(s, viewer) {
|
|
937
946
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1248,8 +1257,8 @@ async function viewAgentSession(s) {
|
|
|
1248
1257
|
const name = esc(a.display || a.name)
|
|
1249
1258
|
const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
|
|
1250
1259
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1251
|
-
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1252
|
-
'<div class="actions">' + agentReplyButton(a) + '</div></div>'
|
|
1260
|
+
html += '<div class="pagehead s-head"><div class="s-head-main"><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1261
|
+
'<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
|
|
1253
1262
|
// v0.5.6: agent.url only links the name; the reply affordance and its
|
|
1254
1263
|
// words come from the feed (agent.reply, agent.replyHint), or nothing.
|
|
1255
1264
|
html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
|
|
@@ -1270,19 +1279,37 @@ async function viewAgentSession(s) {
|
|
|
1270
1279
|
$app.innerHTML = banners() + html
|
|
1271
1280
|
}
|
|
1272
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
|
+
}
|
|
1273
1295
|
async function viewSession(id) {
|
|
1274
1296
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1275
1297
|
const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1276
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)
|
|
1277
1301
|
const branch = s.repo && s.repo.branch
|
|
1278
|
-
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])
|
|
1279
1304
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1280
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)
|
|
1281
1306
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1282
|
-
html += '<div class="pagehead"><div>' + (canRename
|
|
1283
|
-
? '<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">' +
|
|
1284
|
-
: '<h1>' +
|
|
1285
|
-
|
|
1307
|
+
html += '<div class="pagehead s-head"><div class="s-head-main">' + (canRename
|
|
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>'
|
|
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>' +
|
|
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
|
|
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
|
|
1286
1313
|
html += '<div class="facts">' +
|
|
1287
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)) +
|
|
1288
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) +
|
|
@@ -1387,8 +1414,16 @@ function liveAfterRender(sid) {
|
|
|
1387
1414
|
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1388
1415
|
if (ol && !ol.dataset.wired) {
|
|
1389
1416
|
ol.dataset.wired = '1'
|
|
1390
|
-
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
|
+
}
|
|
1391
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
|
|
1392
1427
|
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1393
1428
|
const want = parseLiveRoute(location.hash)
|
|
1394
1429
|
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
@@ -1401,18 +1436,32 @@ function liveAfterRender(sid) {
|
|
|
1401
1436
|
}
|
|
1402
1437
|
liveTick()
|
|
1403
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.
|
|
1404
1441
|
function liveJumpButton(thread, n) {
|
|
1405
1442
|
const b = thread && thread.querySelector('.live-jump')
|
|
1406
1443
|
if (!b) return
|
|
1407
|
-
|
|
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
|
|
1408
1450
|
const c = b.querySelector('.live-jump-n')
|
|
1409
|
-
if (c) c.textContent =
|
|
1451
|
+
if (c) c.textContent = v.count
|
|
1452
|
+
b.setAttribute('aria-label', v.label)
|
|
1410
1453
|
}
|
|
1411
1454
|
function liveJumpClick(btn) {
|
|
1412
1455
|
const thread = btn.closest('.live-thread')
|
|
1413
1456
|
const ol = thread && thread.querySelector('.live-log')
|
|
1414
|
-
|
|
1415
|
-
|
|
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)
|
|
1416
1465
|
}
|
|
1417
1466
|
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1418
1467
|
async function liveEarlierClick(btn) {
|
package/ui/live-view.js
CHANGED
|
@@ -13,7 +13,12 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
13
13
|
// its repo and branch.
|
|
14
14
|
export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
15
15
|
if (!s) return untitled
|
|
16
|
-
|
|
16
|
+
const wrapped = isWrapped(s.title)
|
|
17
|
+
if (s.title && !wrapped) return s.title
|
|
18
|
+
// No name yet (or a raw <channel …> one an earlier beta kept): the first
|
|
19
|
+
// prompt's words, which another Mac's older Leftoff may have sent without a title.
|
|
20
|
+
const first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
|
|
21
|
+
if (first && (isLiveSession(s) || wrapped)) return first
|
|
17
22
|
if (isLiveSession(s)) {
|
|
18
23
|
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
|
|
19
24
|
const branch = s.branch || (s.repo && s.repo.branch) || null
|
|
@@ -24,6 +29,33 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
|
24
29
|
return untitled
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
// Is the card's name just where it runs ("repo · branch")? Then the repo chip
|
|
33
|
+
// under it would say the same thing twice.
|
|
34
|
+
export function titleIsWhere(s) {
|
|
35
|
+
if (!s || !isLiveSession(s)) return false
|
|
36
|
+
const t = sessionTitle(s)
|
|
37
|
+
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || ''
|
|
38
|
+
const branch = s.branch || (s.repo && s.repo.branch) || ''
|
|
39
|
+
return !!repo && (t === repo || t === repo + ' · ' + branch)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
|
|
43
|
+
const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
|
|
44
|
+
// The first line of a prompt in its own words: no channel wrapper (even one
|
|
45
|
+
// cut short), no system reminders; 60 characters at most.
|
|
46
|
+
export function promptLine(text) {
|
|
47
|
+
let t = String(text || '').replace(/^\s*<channel\b[^>]*>?/, '').replace(/<\/channel>\s*$/, '').replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
|
|
48
|
+
t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
|
|
49
|
+
t = t.replace(/\s+/g, ' ')
|
|
50
|
+
return t.length > 60 ? t.slice(0, 59) + '…' : t
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A title as HTML that wraps where it reads: after a "/" in a repo or branch
|
|
54
|
+
// (and at the spaces around " · "), not in the middle of a word.
|
|
55
|
+
export function titleHtml(text, esc) {
|
|
56
|
+
return esc(String(text == null ? '' : text)).split('/').join('/<wbr>')
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
// Which pills a card shows: a live session that is just waiting for you shows
|
|
28
60
|
// one Live pill (never "dormant"); one that is working or needs you shows that too.
|
|
29
61
|
export function cardPills(s) {
|
|
@@ -67,6 +99,14 @@ export function senderTitle(m) {
|
|
|
67
99
|
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
68
100
|
return scrollHeight - clientHeight - scrollTop <= slack
|
|
69
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
|
+
}
|
|
70
110
|
// After new content: follow it, or leave the view where it is and count what is new.
|
|
71
111
|
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
72
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).
|