@leftoff/cli 0.9.8-beta.1 → 0.9.8-beta.11

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,387 @@
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
+ import { parseAttachedLines } from './attach.js'
8
+
9
+ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
10
+ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
11
+
12
+ // The name a session goes by. A live session is never "Untitled": until it has
13
+ // a title (its first prompt, Claude Code's /rename, a generated one) it goes by
14
+ // its repo and branch.
15
+ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
16
+ if (!s) return untitled
17
+ const wrapped = isWrapped(s.title)
18
+ if (s.title && !wrapped) return s.title
19
+ // No name yet (or a raw <channel …> one an earlier beta kept): the first
20
+ // prompt's words, which another Mac's older Leftoff may have sent without a title.
21
+ const first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
22
+ if (first && (isLiveSession(s) || wrapped)) return first
23
+ if (isLiveSession(s)) {
24
+ const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
25
+ const branch = s.branch || (s.repo && s.repo.branch) || null
26
+ const where = [repo, branch && branch !== repo ? branch : null].filter(Boolean).join(' · ')
27
+ if (where) return where
28
+ return 'Live session'
29
+ }
30
+ return untitled
31
+ }
32
+
33
+ // Is the card's name just where it runs ("repo · branch")? Then the repo chip
34
+ // under it would say the same thing twice.
35
+ export function titleIsWhere(s) {
36
+ if (!s || !isLiveSession(s)) return false
37
+ const t = sessionTitle(s)
38
+ const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || ''
39
+ const branch = s.branch || (s.repo && s.repo.branch) || ''
40
+ return !!repo && (t === repo || t === repo + ' · ' + branch)
41
+ }
42
+
43
+ const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
44
+ const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
45
+ // The first line of a prompt in its own words: no channel wrapper (even one
46
+ // cut short), no system reminders; 60 characters at most.
47
+ export function promptLine(text) {
48
+ let t = String(text || '').replace(/^\s*<channel\b[^>]*>?/, '').replace(/<\/channel>\s*$/, '').replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
49
+ t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
50
+ t = t.replace(/\s+/g, ' ')
51
+ return t.length > 60 ? t.slice(0, 59) + '…' : t
52
+ }
53
+
54
+ // A title as HTML that wraps where it reads: after a "/" in a repo or branch
55
+ // (and at the spaces around " · "), not in the middle of a word.
56
+ export function titleHtml(text, esc) {
57
+ return esc(String(text == null ? '' : text)).split('/').join('/<wbr>')
58
+ }
59
+
60
+ // Which pills a card shows: a live session that is just waiting for you shows
61
+ // one Live pill (never "dormant"); one that is working or needs you shows that too.
62
+ export function cardPills(s) {
63
+ const live = isLiveSession(s)
64
+ const st = s && s.status
65
+ if (!live) return { status: st || null, live: false }
66
+ return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
67
+ }
68
+
69
+ // Is Claude running a turn in this live session right now? The Live pill's dot
70
+ // pulses while it is. The same signals the board already shows: the session's
71
+ // own status (a turn typed in the terminal, which the hooks see start), or the
72
+ // board message whose turn is still going, read the way its Working bubble
73
+ // reads it (spinning: sent, received or working; not waiting for your OK, not
74
+ // gone quiet). Idle, waiting on you, or another Mac's daemon that doesn't say:
75
+ // a steady dot.
76
+ export function liveWorking(s, now = Date.now()) {
77
+ if (!cardPills(s).live) return false
78
+ if (s.status === 'active') return true
79
+ const t = s.liveLink && s.liveLink.turn
80
+ if (!t || !t.phase) return false
81
+ const v = statusView(t, now)
82
+ return !!(v && v.spin)
83
+ }
84
+ const LIVE_TIP = 'Live link: send this session messages from the board or your phone'
85
+ export function liveBadgeHtml(s, now = Date.now()) {
86
+ if (!cardPills(s).live) return ''
87
+ const w = liveWorking(s, now)
88
+ return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
89
+ }
90
+
91
+ // The board redraws a card (innerHTML) on every update, which would restart a
92
+ // CSS pulse each time: a visible hitch every second or so while a session
93
+ // works. Pinning every pulse to the page's own clock (start time 0) makes a
94
+ // fresh element pick up exactly where the one it replaced was, and keeps the
95
+ // dots on the page beating together. Reduced motion has no animations to pin.
96
+ export const PULSE_NAMES = new Set(['live-pulse', 'pulse', 'needpulse'])
97
+ export function lockPulses(doc) {
98
+ if (!doc || typeof doc.getAnimations !== 'function') return 0
99
+ let n = 0
100
+ for (const a of doc.getAnimations()) {
101
+ if (!PULSE_NAMES.has(a.animationName) || a.startTime === 0) continue
102
+ try { a.startTime = 0; n++ } catch {}
103
+ }
104
+ return n
105
+ }
106
+
107
+ // The "N empty sessions" fold never takes a live session.
108
+ export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
109
+
110
+ // Who sent a message: "You · from <device>". The path (relay, direct,
111
+ // Tailscale) is added only when it explains something: the message didn't arrive.
112
+ const PATHS = { relay: 'through the relay', direct: 'direct', tailscale: 'over Tailscale', peer: 'through another Mac', local: null }
113
+ export function senderLabel(m) {
114
+ if (!m) return ''
115
+ if (m.from === 'claude') return 'Claude'
116
+ if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
117
+ if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
118
+ if (m.history && !m.by) return 'You · board'
119
+ const by = m.by || {}
120
+ let device = by.device || null
121
+ if (!device && by.transport === 'local') device = 'this Mac'
122
+ if (!device && m.where) { // older entries: just the where line
123
+ const w = String(m.where)
124
+ device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
125
+ }
126
+ let label = 'You' + (device ? ' · from ' + device : '')
127
+ const path = PATHS[by.transport] || null
128
+ if (path && m.state === 'failed') label += ' (' + path + ')'
129
+ return label
130
+ }
131
+ export function senderTitle(m) {
132
+ const by = (m && m.by) || {}
133
+ const path = PATHS[by.transport] || null
134
+ return [by.device ? 'From ' + by.device : null, path ? 'Path: ' + path : by.transport === 'local' ? 'Path: this Mac' : null].filter(Boolean).join(' · ') || (m && m.where) || ''
135
+ }
136
+
137
+ // Stick to the bottom? Only when you are already within `slack` px of it.
138
+ export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
139
+ return scrollHeight - clientHeight - scrollTop <= slack
140
+ }
141
+ // "Jump to latest ↓": shown whenever a thread that scrolls is away from its bottom, with the
142
+ // count of what came in since when there is any; at the bottom it's hidden and nothing is unseen.
143
+ export function jumpView(box = {}, unseen = 0) {
144
+ const { scrollHeight = 0, clientHeight = 0 } = box || {}
145
+ const away = scrollHeight > clientHeight + 1 && !atBottom(box)
146
+ const n = away && unseen > 0 ? unseen : 0
147
+ return { show: away, unseen: n, count: n > 0 ? ' · ' + n + ' new' : '', label: 'Jump to latest message' + (n > 0 ? ' (' + n + ' new)' : '') }
148
+ }
149
+ // After new content: follow it, or leave the view where it is and count what is new.
150
+ export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
151
+ if (wasAtBottom) return { follow: true, unseen: 0 }
152
+ return { follow: false, unseen: unseen + Math.max(0, newCount) }
153
+ }
154
+
155
+ // #/s/<id>/live: the session page, scrolled to the Live box with its input ready.
156
+ export function liveHref(id) { return '#/s/' + encodeURIComponent(String(id)) + '/live' }
157
+ export function parseLiveRoute(hash) {
158
+ const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
159
+ const p = h.split('/').filter(Boolean)
160
+ return p[0] === 's' && p[1] && p[2] === 'live' ? decodeURIComponent(p[1]) : null
161
+ }
162
+
163
+ // An unread dot on the Chat button: Claude replied after you last looked.
164
+ export function hasUnread(lastReplyAt, seenAt) {
165
+ const r = Date.parse(lastReplyAt || '')
166
+ if (isNaN(r)) return false
167
+ const s = Date.parse(seenAt || '')
168
+ return isNaN(s) || r > s
169
+ }
170
+
171
+ // ------------------------------------------------------------ the thread's HTML
172
+
173
+ // opts: { esc, ago, md (markdown -> html), now }
174
+ export function entryKey(m) { return String(m.id || '') + ':' + m.from }
175
+ // "Show earlier" at the top of the thread, when older turns are left out.
176
+ export function earlierHtml(n, { esc }) {
177
+ if (!(n > 0)) return ''
178
+ 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>'
179
+ }
180
+ // A message's time is when it was sent (a clock time today, the date before):
181
+ // a relative "just now" drawn once stays "just now" while the bubble sits there.
182
+ export function sentLabel(iso, now = Date.now()) {
183
+ const t = iso ? Date.parse(iso) : NaN
184
+ if (!Number.isFinite(t)) return ''
185
+ const d = new Date(t), n = new Date(now)
186
+ const today = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
187
+ return today ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
188
+ }
189
+ function timeHtml(iso, { esc, now }) {
190
+ const label = sentLabel(iso, now)
191
+ if (!label) return ''
192
+ const full = new Date(Date.parse(iso)).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
193
+ return '<time class="faint" datetime="' + esc(new Date(Date.parse(iso)).toISOString()) + '" title="' + esc(full) + '">' + esc(label) + '</time>'
194
+ }
195
+ // 0.9.8: a message's attachments, as the board shows them (chips, not paths).
196
+ // From the board's own log (m.attachments) or, for a message the transcript
197
+ // gave back, its "Attached: …/checkpoints/uploads/…" lines.
198
+ export function messageAttachments(m) {
199
+ if (!m || m.from !== 'you') return { text: (m && m.text) || '', attachments: [] }
200
+ if (Array.isArray(m.attachments) && m.attachments.length) return { text: m.text || '', attachments: m.attachments }
201
+ if (!/Attached: /.test(m.text || '')) return { text: m.text || '', attachments: [] }
202
+ return parseAttachedLines(m.text)
203
+ }
204
+ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, attHtml = null }) {
205
+ const who = senderLabel(m)
206
+ const title = m.from === 'you' ? senderTitle(m) : ''
207
+ let body
208
+ if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
209
+ else if (m.from === 'you') {
210
+ const a = messageAttachments(m)
211
+ body = (a.text || !a.attachments.length ? '<div class="lv-text">' + esc(a.text) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
212
+ } else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
213
+ let foot = ''
214
+ if (m.from === 'claude') {
215
+ // what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
216
+ const st = status || null
217
+ if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
218
+ else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
219
+ if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
220
+ const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
221
+ 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>'
222
+ } else if (m.status) {
223
+ foot = deliveryHtml(m.status, { esc, error: m.errorText })
224
+ } else if (m.state) {
225
+ foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
226
+ }
227
+ const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
228
+ 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) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
229
+ }
230
+
231
+ const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
232
+ const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
233
+
234
+ // Under your message: only whether it got there, like a chat app.
235
+ export function deliveryHtml(st, { esc, error = null }) {
236
+ if (!st) return ''
237
+ if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
238
+ const got = st.phase !== 'sent'
239
+ // folded into a turn Claude was already on: the reply above answers it too, no Working bubble of its own
240
+ if (st.folded) return '<div class="lv-tick got lv-folded" role="status" title="It arrived while Claude was working, so Claude took it into that turn">Received <span aria-hidden="true">✓✓</span> · ' + esc(st.phase === 'replied' ? 'Answered with the reply above' : 'Taken into the turn above') + '</div>'
241
+ 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>'
242
+ }
243
+
244
+ // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
245
+ export function midClip(s, n = 56) {
246
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
247
+ if (t.length <= n) return t
248
+ const head = Math.ceil((n - 1) * 0.55)
249
+ return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
250
+ }
251
+ 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>'
252
+
253
+ // The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
254
+ export function workingHtml(st, { esc, now = Date.now() }) {
255
+ const v = statusView(st, now)
256
+ if (!v) return ''
257
+ let label
258
+ if (st.waiting) label = 'Waiting for your OK'
259
+ else if (v.quiet) label = 'Quiet for a while · check the terminal'
260
+ else if (st.phase === 'sent') label = 'Waiting for the session'
261
+ else if (st.phase === 'received') label = 'Starting'
262
+ else label = 'Working'
263
+ 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>'
264
+ const recent = (st.ran || []).slice(-3)
265
+ for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
266
+ if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
267
+ else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
268
+ return html + '</div>'
269
+ }
270
+
271
+ // After the turn: the tools folded into "Ran 3 commands".
272
+ export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
273
+ const ran = (st && st.ran) || []
274
+ if (!ran.length) return ''
275
+ const bash = ran.every((r) => r.tool === 'Bash')
276
+ const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
277
+ const v = statusView(st, now)
278
+ const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
279
+ return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
280
+ }
281
+
282
+ // Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
283
+ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
284
+ const st = you.status
285
+ const key = 'ans_' + you.id + ':claude'
286
+ let inner
287
+ 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 })
288
+ else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
289
+ else inner = workingHtml(st, { esc, now })
290
+ return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
291
+ }
292
+
293
+ // The whole thread: each message you sent, then Claude's side of that turn.
294
+ export function threadHtml(msgs, opts) {
295
+ const list = msgs || []
296
+ const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
297
+ const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
298
+ let html = ''
299
+ for (const m of list) {
300
+ 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 }
301
+ html += entryHtml(m, opts)
302
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
303
+ }
304
+ return html
305
+ }
306
+
307
+ export function statusHtml(st, { esc, now = Date.now(), error = null }) {
308
+ const v = statusView(st, now)
309
+ if (!v) return ''
310
+ const label = st.phase === 'failed' && error ? error : v.label
311
+ 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>')
312
+ return '<div class="lv-status ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '"' + (v.final ? ' data-final="' + esc(String(st.endedAt || '')) + '"' : '') + ' role="status">' +
313
+ (st.phase === 'replied' // done: the steps say it all, with the time it took
314
+ ? '<div class="lvs-steps">' + steps + '<span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>'
315
+ : '<div class="lvs-steps">' + steps + '</div>' +
316
+ '<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>')
317
+ }
318
+
319
+ export { ranWithoutAsking }
320
+
321
+ // A content signature, to replace only the entries that changed (the time-ago
322
+ // text is left out so a minute ticking over doesn't redraw a reply).
323
+ 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) }
324
+ export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
325
+
326
+ // ------------------------------------------------------------ the DOM keeper
327
+
328
+ // A freshly drawn thread (a page load or refresh) opens at its latest message,
329
+ // and stays there while late layout (fonts, code blocks) settles, unless you
330
+ // scroll up in the meantime.
331
+ export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
332
+ let pinned = -1
333
+ const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
334
+ pin()
335
+ raf(() => { pin(); raf(pin) })
336
+ setTimeout(pin, 250)
337
+ if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
338
+ }
339
+
340
+ // Keeps the thread's <ol> across re-renders of the session page.
341
+ export function createLiveKeeper() {
342
+ const kept = { sid: null, wrap: null, unseen: 0 }
343
+ return {
344
+ // Before the page is replaced: hold on to the current thread.
345
+ before(root) {
346
+ const w = root && root.querySelector('.live-thread')
347
+ // a detached element loses its scroll position (WebKit), so remember it and put it back in after()
348
+ const ol = w && w.querySelector('.live-log')
349
+ kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
350
+ if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
351
+ },
352
+ // After: put the old thread back in place of the new one and diff the new entries in.
353
+ after(root) {
354
+ const fresh = root && root.querySelector('.live-thread')
355
+ if (!fresh) { kept.wrap = null; return null }
356
+ const old = kept.wrap
357
+ 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 } }
358
+ const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
359
+ fresh.replaceWith(old)
360
+ if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
361
+ if (kept.scroll) oldOl.scrollTop = kept.scroll.top
362
+ const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
363
+ const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
364
+ const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
365
+ let added = 0, older = 0
366
+ let prev = null
367
+ let seenKept = false
368
+ for (const li of [...newOl.children]) {
369
+ const cur = have.get(li.dataset.key)
370
+ have.delete(li.dataset.key)
371
+ // added above everything already shown ("Show earlier"): older, not new
372
+ // Claude's "Working" bubble isn't news by itself; the answer that replaces it is
373
+ if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
374
+ if (!cur.dataset.older) seenKept = true
375
+ if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
376
+ }
377
+ for (const li of have.values()) li.remove()
378
+ const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
379
+ kept.unseen = d.unseen
380
+ if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
381
+ else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
382
+ return { thread: old, follow: d.follow, unseen: d.unseen }
383
+ },
384
+ seen() { kept.unseen = 0 },
385
+ get unseen() { return kept.unseen },
386
+ }
387
+ }
@@ -0,0 +1,96 @@
1
+ // 0.9.8: the board stays live after a phone wakes up.
2
+ //
3
+ // iOS suspends a Home Screen app (and its live-update long-poll or event
4
+ // stream) when you switch away or lock the screen. A request that was in
5
+ // flight then may never settle, so the stream can go quiet forever while the
6
+ // page still shows a confident "Working · 5m" timer. The pieces here are
7
+ // pure (unit tested in test/unit/livewake-098.test.mjs); ui/app.js wires them:
8
+ // - the daemon sends a ping on the stream every HEARTBEAT_MS (a long-poll
9
+ // answers at least that often), and a stream with nothing for STREAM_DEAD_MS
10
+ // is closed and opened again (with backoff), then the view refetches
11
+ // - coming back (visible, pageshow, focus, online) refetches at once
12
+ // - while a Live message on screen is still going, its thread is polled every
13
+ // POLL_MS, so a dead stream can't leave a Working bubble behind
14
+ // - a pending bubble whose data is older than STALE_MS says "Reconnecting…"
15
+
16
+ export const HEARTBEAT_MS = 15000 // the daemon's stream ping (src/server.js)
17
+ export const STREAM_DEAD_MS = 2 * HEARTBEAT_MS + 5000
18
+ export const POLL_MS = 5000
19
+ export const STALE_MS = 15000
20
+ export const AWAY_MS = 2000 // focus or visible after less than this in the background isn't a wake-up (unless the stream died)
21
+
22
+ const PENDING = new Set(['sent', 'received', 'working'])
23
+
24
+ // A board message whose turn is still going (its bubble spins or waits).
25
+ export function isPending(m) {
26
+ const st = m && m.status
27
+ return !!(st && PENDING.has(st.phase))
28
+ }
29
+ export function anyPending(msgs) { return (msgs || []).some(isPending) }
30
+
31
+ // Should the Live thread be polled right now?
32
+ export function shouldPoll({ visible = true, onLive = false, pending = false } = {}) {
33
+ return !!(visible && onLive && pending)
34
+ }
35
+
36
+ // Has the stream been silent longer than a ping allows?
37
+ export function streamDead(lastAt, now = Date.now(), limit = STREAM_DEAD_MS) {
38
+ if (!lastAt) return false
39
+ return now - lastAt > limit
40
+ }
41
+
42
+ // Is what the page shows too old to trust a running timer?
43
+ export function isStale(okAt, now = Date.now(), limit = STALE_MS) {
44
+ if (!okAt) return false
45
+ return now - okAt > limit
46
+ }
47
+
48
+ // Reconnect delays: 1 s, 2 s, 4 s … at most 30 s.
49
+ export function backoffMs(attempt) {
50
+ const n = Math.max(0, Math.floor(Number(attempt) || 0))
51
+ return Math.min(30000, 1000 * 2 ** Math.min(n, 5))
52
+ }
53
+
54
+ // What a refetch compares: has anything in the thread changed?
55
+ export function liveSignature(msgs) {
56
+ return (msgs || []).map((m) => {
57
+ const st = m.status || {}
58
+ return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0].join(':')
59
+ }).join('|')
60
+ }
61
+
62
+ // Which reasons to refetch: coming back to the page or the network.
63
+ export function wakeReason(type, { visibilityState = 'visible', persisted = false } = {}) {
64
+ if (type === 'visibilitychange') return visibilityState === 'visible' ? 'visible' : null
65
+ if (type === 'pageshow') return persisted ? 'pageshow-bfcache' : null // a plain pageshow is the first load, already fetching
66
+ if (type === 'focus' || type === 'online') return type
67
+ return null
68
+ }
69
+
70
+ // Coalesce wake-ups (visible + focus + pageshow all fire together on iOS).
71
+ export function createWake({ now = () => Date.now(), minGapMs = 1500 } = {}) {
72
+ let last = 0
73
+ return {
74
+ due() { const t = now(); if (t - last < minGapMs) return false; last = t; return true },
75
+ reset() { last = 0 },
76
+ }
77
+ }
78
+
79
+ // The stream watchdog: call seen() on every event or ping, check() on a timer.
80
+ // check() answers 'ok', or 'dead' once (then waits for the reconnect's delay).
81
+ export function createWatchdog({ now = () => Date.now(), limit = STREAM_DEAD_MS } = {}) {
82
+ let lastAt = now(), attempt = 0, nextAt = 0
83
+ return {
84
+ seen() { lastAt = now(); attempt = 0; nextAt = 0 },
85
+ opened() { lastAt = now() },
86
+ check() {
87
+ const t = now()
88
+ if (!streamDead(lastAt, t, limit)) return 'ok'
89
+ if (t < nextAt) return 'wait'
90
+ nextAt = t + backoffMs(attempt++)
91
+ return 'dead'
92
+ },
93
+ get lastAt() { return lastAt },
94
+ get attempt() { return attempt },
95
+ }
96
+ }
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/navhide.js ADDED
@@ -0,0 +1,35 @@
1
+ // Hide the nav on scroll (phones, 0.9.8). Under 700px wide the top row and the
2
+ // bottom tab bar slide away while you scroll down a page and come back when
3
+ // you scroll up, reach the top or the very bottom, Safari style. Never while
4
+ // you're typing, a menu or sheet is open, or the page barely scrolls. Pure
5
+ // functions here (shared with the unit tests); app.js wires the scroll.
6
+
7
+ export const NAVHIDE_DOWN = 10 // px scrolled down in one go before the bars go
8
+ export const NAVHIDE_UP = 10 // px scrolled up in one go before they come back
9
+ export const NAVHIDE_TOP = 56 // this near the top they always show
10
+ export const NAVHIDE_BOTTOM = 8 // this near the very bottom they always show
11
+
12
+ // What counts as typing: a focused field the keyboard is up for.
13
+ export const NAVHIDE_TYPING = 'textarea, [contenteditable=""], [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="range"]):not([type="color"]):not([type="file"]):not([type="hidden"])'
14
+
15
+ // One scroll reading → the next state. state: { hidden, anchor } where anchor
16
+ // is where the current run of scrolling in one direction started. reading:
17
+ // { y: scrollY, max: how far the page can scroll, blocked: typing / a menu
18
+ // open / the bars held (a route change) }.
19
+ export function navHideStep(state, { y = 0, max = 0, blocked = false } = {}) {
20
+ const at = Number(y) || 0
21
+ const prev = state && typeof state === 'object' ? state : { hidden: false, anchor: at } // no state yet: resting here
22
+ const room = Number(max) || 0
23
+ const show = { hidden: false, anchor: at }
24
+ if (blocked) return show
25
+ if (room < NAVHIDE_TOP + NAVHIDE_DOWN + NAVHIDE_BOTTOM) return show // too short to bother
26
+ if (at <= NAVHIDE_TOP) return show
27
+ if (at >= room - NAVHIDE_BOTTOM) return show
28
+ const anchor = Number.isFinite(prev.anchor) ? prev.anchor : at
29
+ if (prev.hidden) {
30
+ if (at > anchor) return { hidden: true, anchor: at } // still going down: follow it
31
+ return anchor - at >= NAVHIDE_UP ? show : { hidden: true, anchor }
32
+ }
33
+ if (at < anchor) return { hidden: false, anchor: at } // going up while shown: follow it
34
+ return at - anchor >= NAVHIDE_DOWN ? { hidden: true, anchor: at } : { hidden: false, anchor }
35
+ }