@leftoff/cli 0.9.8-beta.1 → 0.9.8-beta.3
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 +15 -0
- package/bin/leftoff.js +370 -358
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +226 -13
- package/ui/app.js +336 -20
- package/ui/index.html +14 -6
- package/ui/live-status.js +90 -0
- package/ui/live-view.js +278 -0
- package/ui/nav.js +88 -0
- package/ui/needs.js +32 -1
- package/ui/pull.js +31 -0
- package/ui/sw.js +14 -1
- package/ui/tips.js +117 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 0.9.8 Live link: what a message sent from the board is doing right now,
|
|
2
|
+
// shared by the daemon (src/live-link.js keeps it per message) and the board
|
|
3
|
+
// (which draws it with a spinner and the time since Send).
|
|
4
|
+
//
|
|
5
|
+
// Channels can't stream tokens: Claude answers in one go. What we do see is
|
|
6
|
+
// the channel's delivery (received), the mod's hooks for that session (a turn
|
|
7
|
+
// started, a turn ended), the session's transcript as it is written (each
|
|
8
|
+
// tool call and each block of text) and permission prompts. Those move one
|
|
9
|
+
// message through:
|
|
10
|
+
//
|
|
11
|
+
// sent ─► received ─► working (running Bash: ls · waiting for your OK) ─► replied
|
|
12
|
+
// └─► failed └─► stopped (the turn ended with no answer)
|
|
13
|
+
// received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
|
|
14
|
+
// something happens again; replied and failed are final.
|
|
15
|
+
|
|
16
|
+
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
17
|
+
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
18
|
+
const FINAL = new Set(['replied', 'failed'])
|
|
19
|
+
|
|
20
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
21
|
+
|
|
22
|
+
export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
|
|
23
|
+
|
|
24
|
+
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
25
|
+
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended
|
|
26
|
+
export function stepStatus(st, ev = {}) {
|
|
27
|
+
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
28
|
+
const at = ev.at || s.last
|
|
29
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
|
|
30
|
+
switch (ev.type) {
|
|
31
|
+
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
32
|
+
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
|
33
|
+
case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
|
|
34
|
+
case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; s.waiting = null; break
|
|
35
|
+
case 'tool_done': {
|
|
36
|
+
const name = clip(ev.tool || (s.tool && s.tool.name), 40)
|
|
37
|
+
if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 300), asked: !!ev.asked })
|
|
38
|
+
if (s.ran.length > 20) s.ran = s.ran.slice(-20)
|
|
39
|
+
s.tool = null; s.waiting = null
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; break
|
|
43
|
+
case 'ask_done': s.waiting = null; break
|
|
44
|
+
case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
|
|
45
|
+
case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
|
|
46
|
+
case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
|
|
47
|
+
default: return s
|
|
48
|
+
}
|
|
49
|
+
s.last = at
|
|
50
|
+
return s
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
54
|
+
export function statusView(st, now = Date.now()) {
|
|
55
|
+
if (!st) return null
|
|
56
|
+
const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
57
|
+
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
58
|
+
let label
|
|
59
|
+
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
60
|
+
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
61
|
+
else if (st.phase === 'working') {
|
|
62
|
+
if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
|
|
63
|
+
else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
|
|
64
|
+
else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
|
|
65
|
+
} else if (st.phase === 'replied') label = 'Replied'
|
|
66
|
+
else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
|
|
67
|
+
else label = 'Didn’t arrive'
|
|
68
|
+
const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
|
|
69
|
+
const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
|
|
70
|
+
.map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
|
|
71
|
+
const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
72
|
+
const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
|
|
73
|
+
return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4s, 1m 05s, 12m
|
|
77
|
+
export function elapsedText(ms) {
|
|
78
|
+
const s = Math.max(0, Math.round(Number(ms || 0) / 1000))
|
|
79
|
+
if (s < 60) return s + 's'
|
|
80
|
+
const m = Math.floor(s / 60)
|
|
81
|
+
return m < 10 ? m + 'm ' + String(s % 60).padStart(2, '0') + 's' : m + 'm'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The tools that ran in this turn without a permission prompt: a note under the reply.
|
|
85
|
+
export function ranWithoutAsking(st) {
|
|
86
|
+
const seen = new Set()
|
|
87
|
+
const out = []
|
|
88
|
+
for (const r of (st && st.ran) || []) { if (r.asked || seen.has(r.tool)) continue; seen.add(r.tool); out.push(r.tool) }
|
|
89
|
+
return out
|
|
90
|
+
}
|
package/ui/live-view.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// 0.9.8 Live link on the board: the pieces of the Live box and the live card
|
|
2
|
+
// that are pure (and unit tested), plus the small DOM keeper that lets the Live
|
|
3
|
+
// thread survive the board's re-renders: the same list element stays on the
|
|
4
|
+
// page, messages are diffed in, and it sticks to the bottom only when you are
|
|
5
|
+
// already there.
|
|
6
|
+
import { statusView, elapsedText, ranWithoutAsking } from './live-status.js'
|
|
7
|
+
|
|
8
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
9
|
+
export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
|
|
10
|
+
|
|
11
|
+
// The name a session goes by. A live session is never "Untitled": until it has
|
|
12
|
+
// a title (its first prompt, Claude Code's /rename, a generated one) it goes by
|
|
13
|
+
// its repo and branch.
|
|
14
|
+
export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
15
|
+
if (!s) return untitled
|
|
16
|
+
if (s.title) return s.title
|
|
17
|
+
if (isLiveSession(s)) {
|
|
18
|
+
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
|
|
19
|
+
const branch = s.branch || (s.repo && s.repo.branch) || null
|
|
20
|
+
const where = [repo, branch && branch !== repo ? branch : null].filter(Boolean).join(' · ')
|
|
21
|
+
if (where) return where
|
|
22
|
+
return 'Live session'
|
|
23
|
+
}
|
|
24
|
+
return untitled
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which pills a card shows: a live session that is just waiting for you shows
|
|
28
|
+
// one Live pill (never "dormant"); one that is working or needs you shows that too.
|
|
29
|
+
export function cardPills(s) {
|
|
30
|
+
const live = isLiveSession(s)
|
|
31
|
+
const st = s && s.status
|
|
32
|
+
if (!live) return { status: st || null, live: false }
|
|
33
|
+
return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The "N empty sessions" fold never takes a live session.
|
|
37
|
+
export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
|
|
38
|
+
|
|
39
|
+
// Who sent a message: "You · from <device>". The path (relay, direct,
|
|
40
|
+
// Tailscale) is added only when it explains something: the message didn't arrive.
|
|
41
|
+
const PATHS = { relay: 'through the relay', direct: 'direct', tailscale: 'over Tailscale', peer: 'through another Mac', local: null }
|
|
42
|
+
export function senderLabel(m) {
|
|
43
|
+
if (!m) return ''
|
|
44
|
+
if (m.from === 'claude') return 'Claude'
|
|
45
|
+
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
46
|
+
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
47
|
+
if (m.history && !m.by) return 'You · board'
|
|
48
|
+
const by = m.by || {}
|
|
49
|
+
let device = by.device || null
|
|
50
|
+
if (!device && by.transport === 'local') device = 'this Mac'
|
|
51
|
+
if (!device && m.where) { // older entries: just the where line
|
|
52
|
+
const w = String(m.where)
|
|
53
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
54
|
+
}
|
|
55
|
+
let label = 'You' + (device ? ' · from ' + device : '')
|
|
56
|
+
const path = PATHS[by.transport] || null
|
|
57
|
+
if (path && m.state === 'failed') label += ' (' + path + ')'
|
|
58
|
+
return label
|
|
59
|
+
}
|
|
60
|
+
export function senderTitle(m) {
|
|
61
|
+
const by = (m && m.by) || {}
|
|
62
|
+
const path = PATHS[by.transport] || null
|
|
63
|
+
return [by.device ? 'From ' + by.device : null, path ? 'Path: ' + path : by.transport === 'local' ? 'Path: this Mac' : null].filter(Boolean).join(' · ') || (m && m.where) || ''
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stick to the bottom? Only when you are already within `slack` px of it.
|
|
67
|
+
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
68
|
+
return scrollHeight - clientHeight - scrollTop <= slack
|
|
69
|
+
}
|
|
70
|
+
// After new content: follow it, or leave the view where it is and count what is new.
|
|
71
|
+
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
72
|
+
if (wasAtBottom) return { follow: true, unseen: 0 }
|
|
73
|
+
return { follow: false, unseen: unseen + Math.max(0, newCount) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// #/s/<id>/live: the session page, scrolled to the Live box with its input ready.
|
|
77
|
+
export function liveHref(id) { return '#/s/' + encodeURIComponent(String(id)) + '/live' }
|
|
78
|
+
export function parseLiveRoute(hash) {
|
|
79
|
+
const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
|
|
80
|
+
const p = h.split('/').filter(Boolean)
|
|
81
|
+
return p[0] === 's' && p[1] && p[2] === 'live' ? decodeURIComponent(p[1]) : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// An unread dot on the Chat button: Claude replied after you last looked.
|
|
85
|
+
export function hasUnread(lastReplyAt, seenAt) {
|
|
86
|
+
const r = Date.parse(lastReplyAt || '')
|
|
87
|
+
if (isNaN(r)) return false
|
|
88
|
+
const s = Date.parse(seenAt || '')
|
|
89
|
+
return isNaN(s) || r > s
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------ the thread's HTML
|
|
93
|
+
|
|
94
|
+
// opts: { esc, ago, md (markdown -> html), now }
|
|
95
|
+
export function entryKey(m) { return String(m.id || '') + ':' + m.from }
|
|
96
|
+
// "Show earlier" at the top of the thread, when older turns are left out.
|
|
97
|
+
export function earlierHtml(n, { esc }) {
|
|
98
|
+
if (!(n > 0)) return ''
|
|
99
|
+
return '<li class="lv-earlier" data-key="earlier" data-sig="' + esc(String(n)) + '" data-older="1"><button type="button" class="btn live-earlier" data-action="live-earlier">Show earlier <span class="faint">(' + esc(String(n)) + (n === 1 ? ' turn' : ' turns') + ')</span></button></li>'
|
|
100
|
+
}
|
|
101
|
+
export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
|
|
102
|
+
const who = senderLabel(m)
|
|
103
|
+
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
104
|
+
let body
|
|
105
|
+
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
106
|
+
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
107
|
+
let foot = ''
|
|
108
|
+
if (m.from === 'claude') {
|
|
109
|
+
// what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
|
|
110
|
+
const st = status || null
|
|
111
|
+
if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
|
|
112
|
+
else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
|
|
113
|
+
if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
|
|
114
|
+
const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
|
|
115
|
+
if (ran.length) foot += '<div class="lv-note">Ran without asking: ' + esc(ran.join(', ')) + ' <span class="faint">(read-only or allowed in your Claude Code settings, so no Allow/Deny)</span></div>'
|
|
116
|
+
} else if (m.status) {
|
|
117
|
+
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
118
|
+
} else if (m.state) {
|
|
119
|
+
foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
|
|
120
|
+
}
|
|
121
|
+
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
|
|
122
|
+
return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span></div>' + body + foot + '</li>'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
126
|
+
const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
|
|
127
|
+
|
|
128
|
+
// Under your message: only whether it got there, like a chat app.
|
|
129
|
+
export function deliveryHtml(st, { esc, error = null }) {
|
|
130
|
+
if (!st) return ''
|
|
131
|
+
if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
|
|
132
|
+
const got = st.phase !== 'sent'
|
|
133
|
+
return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
|
|
137
|
+
export function midClip(s, n = 56) {
|
|
138
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
139
|
+
if (t.length <= n) return t
|
|
140
|
+
const head = Math.ceil((n - 1) * 0.55)
|
|
141
|
+
return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
|
|
142
|
+
}
|
|
143
|
+
const toolLine = (name, detail, { esc, cls = '' }) => '<div class="lv-tool mono' + (cls ? ' ' + cls : '') + '"' + (detail ? ' title="' + esc(name + ' · ' + detail) + '"' : '') + '>' + esc(name) + (detail ? ' · ' + esc(midClip(detail)) : '') + '</div>'
|
|
144
|
+
|
|
145
|
+
// The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
|
|
146
|
+
export function workingHtml(st, { esc, now = Date.now() }) {
|
|
147
|
+
const v = statusView(st, now)
|
|
148
|
+
if (!v) return ''
|
|
149
|
+
let label
|
|
150
|
+
if (st.waiting) label = 'Waiting for your OK'
|
|
151
|
+
else if (v.quiet) label = 'Quiet for a while · check the terminal'
|
|
152
|
+
else if (st.phase === 'sent') label = 'Waiting for the session'
|
|
153
|
+
else if (st.phase === 'received') label = 'Starting'
|
|
154
|
+
else label = 'Working'
|
|
155
|
+
let html = '<div class="lv-status lv-working ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '" role="status"><div class="lvs-line">' + (v.spin ? '<span class="lv-spin" aria-hidden="true"></span>' : '') + '<span class="lvs-label">' + esc(label) + '</span><span class="lvs-sep" aria-hidden="true">·</span><span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div>'
|
|
156
|
+
const recent = (st.ran || []).slice(-3)
|
|
157
|
+
for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
|
|
158
|
+
if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
|
|
159
|
+
else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
|
|
160
|
+
return html + '</div>'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// After the turn: the tools folded into "Ran 3 commands".
|
|
164
|
+
export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
|
|
165
|
+
const ran = (st && st.ran) || []
|
|
166
|
+
if (!ran.length) return ''
|
|
167
|
+
const bash = ran.every((r) => r.tool === 'Bash')
|
|
168
|
+
const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
|
|
169
|
+
const v = statusView(st, now)
|
|
170
|
+
const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
|
|
171
|
+
return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
|
|
175
|
+
export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
|
|
176
|
+
const st = you.status
|
|
177
|
+
const key = 'ans_' + you.id + ':claude'
|
|
178
|
+
let inner
|
|
179
|
+
if (st.phase === 'stopped') inner = '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>' + toolsHtml(st, { esc, now })
|
|
180
|
+
else inner = workingHtml(st, { esc, now })
|
|
181
|
+
return '<li class="lv lv-claude lv-pending' + (st.phase === 'stopped' ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude <span class="faint">' + esc(ago(st.last ? new Date(st.last).toISOString() : you.at)) + '</span></div>' + inner + '</li>'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The whole thread: each message you sent, then Claude's side of that turn.
|
|
185
|
+
export function threadHtml(msgs, opts) {
|
|
186
|
+
const list = msgs || []
|
|
187
|
+
const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
|
|
188
|
+
const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
|
|
189
|
+
let html = ''
|
|
190
|
+
for (const m of list) {
|
|
191
|
+
if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); continue }
|
|
192
|
+
html += entryHtml(m, opts)
|
|
193
|
+
if (m.status && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
|
|
194
|
+
}
|
|
195
|
+
return html
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function statusHtml(st, { esc, now = Date.now(), error = null }) {
|
|
199
|
+
const v = statusView(st, now)
|
|
200
|
+
if (!v) return ''
|
|
201
|
+
const label = st.phase === 'failed' && error ? error : v.label
|
|
202
|
+
const steps = v.steps.map((x) => '<span class="lvs-step ' + x.state + '">' + esc(x.name) + '</span>').join('<span class="lvs-arrow" aria-hidden="true">→</span>')
|
|
203
|
+
return '<div class="lv-status ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '"' + (v.final ? ' data-final="' + esc(String(st.endedAt || '')) + '"' : '') + ' role="status">' +
|
|
204
|
+
(st.phase === 'replied' // done: the steps say it all, with the time it took
|
|
205
|
+
? '<div class="lvs-steps">' + steps + '<span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>'
|
|
206
|
+
: '<div class="lvs-steps">' + steps + '</div>' +
|
|
207
|
+
'<div class="lvs-line">' + (v.spin ? '<span class="lv-spin" aria-hidden="true"></span>' : '') + '<span class="lvs-label">' + esc(label) + '</span><span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export { ranWithoutAsking }
|
|
211
|
+
|
|
212
|
+
// A content signature, to replace only the entries that changed (the time-ago
|
|
213
|
+
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
214
|
+
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
215
|
+
export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------------------------ the DOM keeper
|
|
218
|
+
|
|
219
|
+
// A freshly drawn thread (a page load or refresh) opens at its latest message,
|
|
220
|
+
// and stays there while late layout (fonts, code blocks) settles, unless you
|
|
221
|
+
// scroll up in the meantime.
|
|
222
|
+
export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
|
|
223
|
+
let pinned = -1
|
|
224
|
+
const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
|
|
225
|
+
pin()
|
|
226
|
+
raf(() => { pin(); raf(pin) })
|
|
227
|
+
setTimeout(pin, 250)
|
|
228
|
+
if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Keeps the thread's <ol> across re-renders of the session page.
|
|
232
|
+
export function createLiveKeeper() {
|
|
233
|
+
const kept = { sid: null, wrap: null, unseen: 0 }
|
|
234
|
+
return {
|
|
235
|
+
// Before the page is replaced: hold on to the current thread.
|
|
236
|
+
before(root) {
|
|
237
|
+
const w = root && root.querySelector('.live-thread')
|
|
238
|
+
// a detached element loses its scroll position (WebKit), so remember it and put it back in after()
|
|
239
|
+
const ol = w && w.querySelector('.live-log')
|
|
240
|
+
kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
|
|
241
|
+
if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
|
|
242
|
+
},
|
|
243
|
+
// After: put the old thread back in place of the new one and diff the new entries in.
|
|
244
|
+
after(root) {
|
|
245
|
+
const fresh = root && root.querySelector('.live-thread')
|
|
246
|
+
if (!fresh) { kept.wrap = null; return null }
|
|
247
|
+
const old = kept.wrap
|
|
248
|
+
if (!old || old.dataset.sid !== fresh.dataset.sid) { kept.unseen = 0; const ol = fresh.querySelector('.live-log'); if (ol) pinToBottom(ol); return { thread: fresh, follow: true, first: true } }
|
|
249
|
+
const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
|
|
250
|
+
fresh.replaceWith(old)
|
|
251
|
+
if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
|
|
252
|
+
if (kept.scroll) oldOl.scrollTop = kept.scroll.top
|
|
253
|
+
const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
|
|
254
|
+
const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
|
|
255
|
+
const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
|
|
256
|
+
let added = 0, older = 0
|
|
257
|
+
let prev = null
|
|
258
|
+
let seenKept = false
|
|
259
|
+
for (const li of [...newOl.children]) {
|
|
260
|
+
const cur = have.get(li.dataset.key)
|
|
261
|
+
have.delete(li.dataset.key)
|
|
262
|
+
// added above everything already shown ("Show earlier"): older, not new
|
|
263
|
+
// Claude's "Working" bubble isn't news by itself; the answer that replaces it is
|
|
264
|
+
if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
|
|
265
|
+
if (!cur.dataset.older) seenKept = true
|
|
266
|
+
if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
|
|
267
|
+
}
|
|
268
|
+
for (const li of have.values()) li.remove()
|
|
269
|
+
const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
|
|
270
|
+
kept.unseen = d.unseen
|
|
271
|
+
if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
|
|
272
|
+
else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
|
|
273
|
+
return { thread: old, follow: d.follow, unseen: d.unseen }
|
|
274
|
+
},
|
|
275
|
+
seen() { kept.unseen = 0 },
|
|
276
|
+
get unseen() { return kept.unseen },
|
|
277
|
+
}
|
|
278
|
+
}
|
package/ui/nav.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The sidebar rail, the phone's bottom bar and Quick look (0.9.8). Pure functions
|
|
2
|
+
// over the daemon's sessions (shared with the unit tests); app.js wires them.
|
|
3
|
+
//
|
|
4
|
+
// Quick look: hover (or long-press on a phone) the Needs you item for the count
|
|
5
|
+
// and the five longest waits, with Allow once / Always / Deny on permission prompts
|
|
6
|
+
// (Always only when the Needs you page offers "don't ask again"). Those
|
|
7
|
+
// buttons are the Needs you page's own (data-action="ask-answer"): same request,
|
|
8
|
+
// same checks, and nothing is ever answered without a click.
|
|
9
|
+
|
|
10
|
+
import { esc, needsItems, needsLine, waitLabel, alwaysText, alwaysButton } from './needs.js'
|
|
11
|
+
|
|
12
|
+
// The nav item a route lights up. A session page belongs to the board, a
|
|
13
|
+
// catch-up to the digest.
|
|
14
|
+
export function navKey(name) {
|
|
15
|
+
if (name === 's' || name === 'board' || !name) return 'board'
|
|
16
|
+
if (name === 'catchup') return 'digest'
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// On a phone the bar holds four sections and More; these live under More.
|
|
21
|
+
export const MORE_KEYS = ['timeline', 'digest', 'whats-new', 'settings']
|
|
22
|
+
export const moreActive = (key) => MORE_KEYS.includes(key)
|
|
23
|
+
|
|
24
|
+
// The red count on Needs you: nothing at zero, 99+ past 99.
|
|
25
|
+
export function badgeText(n) {
|
|
26
|
+
const v = Math.max(0, Math.floor(Number(n) || 0))
|
|
27
|
+
if (!v) return ''
|
|
28
|
+
return v > 99 ? '99+' : String(v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function qlTitle(total) {
|
|
32
|
+
if (!total) return 'Nothing needs you'
|
|
33
|
+
return total + (total === 1 ? ' thing needs' : ' things need') + ' you'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "6m" from the need's wait, or from when it was asked when there's no wait clock.
|
|
37
|
+
export function qlAge(n, now = Date.now()) {
|
|
38
|
+
if (!n) return ''
|
|
39
|
+
if (n.waitSec != null) return waitLabel(n.waitSec).replace('waiting ', '')
|
|
40
|
+
const t = Date.parse(n.since || n.at || '')
|
|
41
|
+
if (!Number.isFinite(t)) return ''
|
|
42
|
+
return waitLabel(Math.max(1, (now - t) / 1000)).replace('waiting ', '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The popover's items: the same order as the Needs you page (blocked first,
|
|
46
|
+
// longest wait first), the first `limit` of them.
|
|
47
|
+
export function quickLookItems(sessions = [], limit = 5) {
|
|
48
|
+
const all = needsItems(sessions)
|
|
49
|
+
return { total: all.length, items: all.slice(0, limit), more: Math.max(0, all.length - limit) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A permission prompt with an id is the one kind answered inline.
|
|
53
|
+
export const qlAnswerable = (n) => !!(n && n.kind === 'permission' && n.ask && n.ask.id)
|
|
54
|
+
|
|
55
|
+
// opts: { mayAnswer, pending: { [askId]: label }, now }
|
|
56
|
+
export function qlRow(n, opts = {}) {
|
|
57
|
+
const s = n.session || {}
|
|
58
|
+
const askId = n.ask && n.ask.id ? n.ask.id : ''
|
|
59
|
+
const key = askId || 'reply:' + s.id
|
|
60
|
+
const mac = n.peer || s.machine || ''
|
|
61
|
+
const meta = [s.repoLabel, mac, qlAge(n, opts.now)].filter(Boolean).join(' · ')
|
|
62
|
+
const line = needsLine(n)
|
|
63
|
+
const always = qlAnswerable(n) ? alwaysText(n.ask) : ''
|
|
64
|
+
let h = '<li class="ql-item k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + (always ? ' has-always' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">' +
|
|
65
|
+
'<a class="ql-main" href="#/s/' + encodeURIComponent(s.id || '') + '">' +
|
|
66
|
+
'<span class="ql-t">' + esc(s.title || 'Untitled session') + '</span>' +
|
|
67
|
+
(line ? '<span class="ql-what" title="' + esc(line) + '">' + esc(line) + '</span>' : '') +
|
|
68
|
+
(meta ? '<span class="ql-meta mono">' + esc(meta) + '</span>' : '') + '</a>'
|
|
69
|
+
if (qlAnswerable(n)) {
|
|
70
|
+
const busy = opts.pending && opts.pending[key]
|
|
71
|
+
const dis = opts.mayAnswer === false || busy ? ' disabled' : ''
|
|
72
|
+
const off = opts.mayAnswer === false ? ' title="Answering from this device is off"' : ''
|
|
73
|
+
h += '<div class="ql-actions">' +
|
|
74
|
+
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + off + ' aria-label="Allow once: ' + esc(line) + '">' + (busy ? esc(busy) : 'Allow once') + '</button>' +
|
|
75
|
+
(always ? alwaysButton(n.ask, dis, { label: 'Always', scope: 'ql', session: s, title: off }) : '') +
|
|
76
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + off + ' aria-label="Deny: ' + esc(line) + '">Deny</button></div>'
|
|
77
|
+
}
|
|
78
|
+
return h + '</li>'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function quickLookHtml(model = {}, opts = {}) {
|
|
82
|
+
const total = model.total || 0
|
|
83
|
+
const items = model.items || []
|
|
84
|
+
let h = '<div class="ql-head"><h2 id="qlTitle">' + esc(qlTitle(total)) + '</h2></div>'
|
|
85
|
+
if (!items.length) h += '<p class="ql-empty">When a session asks for permission or a question, it shows up here.</p>'
|
|
86
|
+
else h += '<ul class="ql-list">' + items.map((n) => qlRow(n, opts)).join('') + '</ul>' + (model.more ? '<p class="ql-more">+' + model.more + ' more</p>' : '')
|
|
87
|
+
return h + '<a class="ql-foot" href="#/waiting">Open Needs you <span aria-hidden="true">→</span></a>'
|
|
88
|
+
}
|
package/ui/needs.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// the panel on a session's page. Pure functions over the daemon's needsYou items
|
|
3
3
|
// (shared with the unit tests); app.js wires the clicks.
|
|
4
4
|
//
|
|
5
|
-
// Kinds: permission (Allow once / Allow for this session / Deny with a note),
|
|
5
|
+
// Kinds: permission (Allow once / Always allow here ⓘ / Allow for this session / Deny with a note),
|
|
6
6
|
// question (AskUserQuestion's options as buttons, plus Other…), terminal (only
|
|
7
7
|
// the terminal can answer: Go), reply (the turn ended on a question: a reply box
|
|
8
8
|
// when the mod can type it in). Nothing is ever answered without a click.
|
|
9
9
|
|
|
10
10
|
import { mdInline } from './changelog-md.js'
|
|
11
|
+
import { tipHtml, tipId } from './tips.js'
|
|
11
12
|
|
|
12
13
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
13
14
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
@@ -56,6 +57,35 @@ export function cardBadge(n) {
|
|
|
56
57
|
export const KBD_SUBMIT = 'Meta+Enter Control+Enter'
|
|
57
58
|
const kbdHint = (opts) => '<span class="kbd-hint" aria-hidden="true">' + esc(opts.kbd || '⌘↵') + '</span>'
|
|
58
59
|
|
|
60
|
+
// 0.9.8 "Always allow here" (Claude Code's "don't ask again"): offered only
|
|
61
|
+
// when Leftoff can name the exact rule(s). One line on the button; the rule(s),
|
|
62
|
+
// the file and the project are in its ⓘ tooltip and in its aria-label.
|
|
63
|
+
export function alwaysText(ask) {
|
|
64
|
+
const a = ask && ask.always
|
|
65
|
+
if (!a || !Array.isArray(a.rules) || !a.rules.length) return ''
|
|
66
|
+
return 'adds ' + a.rules.join(', ') + ' to ' + (a.file || '.claude/settings.local.json')
|
|
67
|
+
}
|
|
68
|
+
// The tooltip's words: "Adds `Bash(x)` to .claude/settings.local.json in api, so
|
|
69
|
+
// Claude won't ask for this again here." (markup; the rules in mono)
|
|
70
|
+
export function alwaysTipHtml(ask, session = null) {
|
|
71
|
+
const a = ask && ask.always
|
|
72
|
+
if (!alwaysText(ask)) return ''
|
|
73
|
+
const where = a.project || (session && session.repoLabel) || ''
|
|
74
|
+
return 'Adds ' + a.rules.map((r) => '<code>' + esc(r) + '</code>').join(', ') + ' to <code>' + esc(a.file || '.claude/settings.local.json') + '</code>' +
|
|
75
|
+
(where ? ' in <b>' + esc(where) + '</b>' : ' in this project') + ', so Claude won’t ask for ' + (a.rules.length > 1 ? 'these' : 'this') + ' again here.'
|
|
76
|
+
}
|
|
77
|
+
// opts: { label ('Always allow here'; Quick look says 'Always'), scope (keeps the
|
|
78
|
+
// tooltip's id unique when Quick look and the page show the same ask), session, title }
|
|
79
|
+
export function alwaysButton(ask, dis = '', opts = {}) {
|
|
80
|
+
const t = alwaysText(ask)
|
|
81
|
+
if (!t) return ''
|
|
82
|
+
const label = opts.label || 'Always allow here'
|
|
83
|
+
const id = tipId(opts.scope || 'always', ask.id)
|
|
84
|
+
return '<span class="always-wrap">' +
|
|
85
|
+
'<button type="button" class="btn always" data-action="ask-answer" data-choice="allow_always"' + dis + (opts.title || '') + ' aria-label="' + esc(label + ': ' + t) + '" aria-describedby="' + esc(id) + '">' + esc(label) + '</button>' +
|
|
86
|
+
tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
// opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
|
|
60
90
|
export function needPanel(n, opts = {}) {
|
|
61
91
|
const s = n.session || {}
|
|
@@ -81,6 +111,7 @@ export function needPanel(n, opts = {}) {
|
|
|
81
111
|
if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
|
|
82
112
|
h += '<div class="need-actions">' +
|
|
83
113
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
114
|
+
alwaysButton(n.ask, dis, { session: s }) +
|
|
84
115
|
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
85
116
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
86
117
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
package/ui/pull.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pull to refresh on phones (0.9.8). Pull down from the top of any page and let
|
|
2
|
+
// go past the line: the page reads its data again (no full reload). Pure
|
|
3
|
+
// functions here (shared with the unit tests); app.js wires the touches.
|
|
4
|
+
|
|
5
|
+
export const PULL_TRIGGER = 70 // px of finger travel that arms a refresh
|
|
6
|
+
export const PULL_MAX = 110 // how far the spinner travels at most
|
|
7
|
+
|
|
8
|
+
// Where a pull never starts: text fields, the Live box, menus and sheets,
|
|
9
|
+
// sideways scrollers, and anything open on top of the page.
|
|
10
|
+
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
|
|
11
|
+
|
|
12
|
+
// The phone layout on a touch screen, at the very top of the page, from a
|
|
13
|
+
// place that isn't a field or an overlay, and with no overlay open.
|
|
14
|
+
export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
|
|
15
|
+
if (!coarse || !narrow || overlayOpen) return false
|
|
16
|
+
if (scrollY > 0) return false
|
|
17
|
+
if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One finger movement: how far the spinner sits (with some resistance), how
|
|
22
|
+
// far round it is, and whether letting go now refreshes. Sideways first means
|
|
23
|
+
// it isn't a pull.
|
|
24
|
+
export function pullStep(dy, dx = 0, { trigger = PULL_TRIGGER, max = PULL_MAX } = {}) {
|
|
25
|
+
if (!(dy > 0) || Math.abs(dx) > dy) return { pulling: false, dist: 0, progress: 0, armed: false }
|
|
26
|
+
const dist = Math.min(max, dy < trigger ? dy * 0.8 : trigger * 0.8 + (dy - trigger) * 0.35)
|
|
27
|
+
return { pulling: true, dist: Math.round(dist), progress: Math.min(1, dy / trigger), armed: dy >= trigger }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What letting go does.
|
|
31
|
+
export const pullRelease = (step) => (step && step.pulling && step.armed ? 'refresh' : 'cancel')
|
package/ui/sw.js
CHANGED
|
@@ -22,13 +22,26 @@ self.addEventListener('fetch', (event) => {
|
|
|
22
22
|
if (req.mode === 'navigate') {
|
|
23
23
|
// A 502/503/504 is Tailscale Serve saying the daemon behind it is down.
|
|
24
24
|
// (The relay's own "Your Macs are asleep" page, x-leftoff-page, shows as is.)
|
|
25
|
-
|
|
25
|
+
// 0.9.7: never an endless blank page. The relay answers within a second
|
|
26
|
+
// (the board, or its own connecting page); anything slower than NAV_TIMEOUT_MS
|
|
27
|
+
// gets a page that says so, with Try again.
|
|
28
|
+
const net = fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline)
|
|
29
|
+
event.respondWith(Promise.race([net, new Promise((resolve) => setTimeout(() => resolve(slow(req.url)), NAV_TIMEOUT_MS))]))
|
|
26
30
|
return
|
|
27
31
|
}
|
|
28
32
|
// The offline page's own files, so it renders with no network.
|
|
29
33
|
if (OFFLINE.includes(url.pathname)) event.respondWith(fetch(req).catch(() => caches.match(url.pathname)))
|
|
30
34
|
})
|
|
31
35
|
|
|
36
|
+
const NAV_TIMEOUT_MS = 12000
|
|
37
|
+
function slow(url) {
|
|
38
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
39
|
+
const body = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Leftoff · still waiting</title>' +
|
|
40
|
+
'<style>body{margin:0;font:16px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:Canvas;color:CanvasText}main{max-width:460px;margin:0 auto;padding:max(48px,env(safe-area-inset-top)) 20px 40px}h1{font-size:26px;margin:0 0 10px}p{opacity:.75}a{display:flex;align-items:center;justify-content:center;min-height:48px;border-radius:12px;background:#d9622b;color:#fff;font-weight:600;text-decoration:none;margin-top:20px}</style></head>' +
|
|
41
|
+
'<body><main><h1>Leftoff is taking too long</h1><p>Your Mac hasn’t sent the board yet. It may be waking up.</p><a href="' + esc(url) + '">Try again</a></main></body></html>'
|
|
42
|
+
return new Response(body, { status: 504, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
function offline() {
|
|
33
46
|
return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
|
|
34
47
|
}
|