@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.
@@ -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
+ }
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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
- event.respondWith(fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline))
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
  }