@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.21

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/ui/live-view.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // page, messages are diffed in, and it sticks to the bottom only when you are
5
5
  // already there.
6
6
  import { statusView, elapsedText, ranWithoutAsking } from './live-status.js'
7
+ import { parseAttachedLines } from './attach.js'
7
8
 
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 }
9
10
  export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
@@ -13,7 +14,12 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
13
14
  // its repo and branch.
14
15
  export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
15
16
  if (!s) return untitled
16
- if (s.title) return s.title
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
17
23
  if (isLiveSession(s)) {
18
24
  const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
19
25
  const branch = s.branch || (s.repo && s.repo.branch) || null
@@ -24,6 +30,33 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
24
30
  return untitled
25
31
  }
26
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
+
27
60
  // Which pills a card shows: a live session that is just waiting for you shows
28
61
  // one Live pill (never "dormant"); one that is working or needs you shows that too.
29
62
  export function cardPills(s) {
@@ -33,6 +66,44 @@ export function cardPills(s) {
33
66
  return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
34
67
  }
35
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
+
36
107
  // The "N empty sessions" fold never takes a live session.
37
108
  export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
38
109
 
@@ -43,6 +114,8 @@ export function senderLabel(m) {
43
114
  if (!m) return ''
44
115
  if (m.from === 'claude') return 'Claude'
45
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'
46
119
  const by = m.by || {}
47
120
  let device = by.device || null
48
121
  if (!device && by.transport === 'local') device = 'this Mac'
@@ -65,6 +138,14 @@ export function senderTitle(m) {
65
138
  export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
66
139
  return scrollHeight - clientHeight - scrollTop <= slack
67
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
+ }
68
149
  // After new content: follow it, or leave the view where it is and count what is new.
69
150
  export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
70
151
  if (wasAtBottom) return { follow: true, unseen: 0 }
@@ -91,23 +172,137 @@ export function hasUnread(lastReplyAt, seenAt) {
91
172
 
92
173
  // opts: { esc, ago, md (markdown -> html), now }
93
174
  export function entryKey(m) { return String(m.id || '') + ':' + m.from }
94
- export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
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 }) {
95
205
  const who = senderLabel(m)
96
206
  const title = m.from === 'you' ? senderTitle(m) : ''
97
207
  let body
98
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 lv-md">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
212
+ } else if (m.from === 'terminal') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
99
213
  else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
100
214
  let foot = ''
101
215
  if (m.from === 'claude') {
216
+ // what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
217
+ const st = status || null
218
+ if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
219
+ else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
220
+ if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
102
221
  const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
103
- if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
104
222
  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>'
105
223
  } else if (m.status) {
106
- foot = statusHtml(m.status, { esc, now, error: m.errorText })
224
+ foot = deliveryHtml(m.status, { esc, error: m.errorText })
107
225
  } else if (m.state) {
108
- foot = '<div class="lv-status ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received by the session' : 'Sent, waiting for the session') + '</div>'
226
+ foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
227
+ }
228
+ const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
229
+ 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>'
230
+ }
231
+
232
+ const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
233
+ const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
234
+
235
+ // Under your message: only whether it got there, like a chat app.
236
+ export function deliveryHtml(st, { esc, error = null }) {
237
+ if (!st) return ''
238
+ if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
239
+ const got = st.phase !== 'sent'
240
+ // folded into a turn Claude was already on: the reply above answers it too, no Working bubble of its own
241
+ 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>'
242
+ 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>'
243
+ }
244
+
245
+ // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
246
+ export function midClip(s, n = 56) {
247
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
248
+ if (t.length <= n) return t
249
+ const head = Math.ceil((n - 1) * 0.55)
250
+ return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
251
+ }
252
+ 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>'
253
+
254
+ // The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
255
+ export function workingHtml(st, { esc, now = Date.now() }) {
256
+ const v = statusView(st, now)
257
+ if (!v) return ''
258
+ let label
259
+ if (st.waiting) label = 'Waiting for your OK'
260
+ else if (v.quiet) label = 'Quiet for a while · check the terminal'
261
+ else if (st.phase === 'sent') label = 'Waiting for the session'
262
+ else if (st.phase === 'received') label = 'Starting'
263
+ else label = 'Working'
264
+ 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>'
265
+ const recent = (st.ran || []).slice(-3)
266
+ for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
267
+ if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
268
+ else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
269
+ return html + '</div>'
270
+ }
271
+
272
+ // After the turn: the tools folded into "Ran 3 commands".
273
+ export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
274
+ const ran = (st && st.ran) || []
275
+ if (!ran.length) return ''
276
+ const bash = ran.every((r) => r.tool === 'Bash')
277
+ const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
278
+ const v = statusView(st, now)
279
+ const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
280
+ return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
281
+ }
282
+
283
+ // Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
284
+ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
285
+ const st = you.status
286
+ const key = 'ans_' + you.id + ':claude'
287
+ let inner
288
+ 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 })
289
+ 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
290
+ else inner = workingHtml(st, { esc, now })
291
+ 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>'
292
+ }
293
+
294
+ // The whole thread: each message you sent, then Claude's side of that turn.
295
+ export function threadHtml(msgs, opts) {
296
+ const list = msgs || []
297
+ const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
298
+ const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
299
+ let html = ''
300
+ for (const m of list) {
301
+ 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 }
302
+ html += entryHtml(m, opts)
303
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
109
304
  }
110
- return '<li class="lv lv-' + esc(m.from) + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(entrySig(m))) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span></div>' + body + foot + '</li>'
305
+ return html
111
306
  }
112
307
 
113
308
  export function statusHtml(st, { esc, now = Date.now(), error = null }) {
@@ -127,10 +322,22 @@ export { ranWithoutAsking }
127
322
  // A content signature, to replace only the entries that changed (the time-ago
128
323
  // text is left out so a minute ticking over doesn't redraw a reply).
129
324
  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) }
130
- 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]) }
325
+ 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]) }
131
326
 
132
327
  // ------------------------------------------------------------ the DOM keeper
133
328
 
329
+ // A freshly drawn thread (a page load or refresh) opens at its latest message,
330
+ // and stays there while late layout (fonts, code blocks) settles, unless you
331
+ // scroll up in the meantime.
332
+ export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
333
+ let pinned = -1
334
+ const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
335
+ pin()
336
+ raf(() => { pin(); raf(pin) })
337
+ setTimeout(pin, 250)
338
+ if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
339
+ }
340
+
134
341
  // Keeps the thread's <ol> across re-renders of the session page.
135
342
  export function createLiveKeeper() {
136
343
  const kept = { sid: null, wrap: null, unseen: 0 }
@@ -138,6 +345,9 @@ export function createLiveKeeper() {
138
345
  // Before the page is replaced: hold on to the current thread.
139
346
  before(root) {
140
347
  const w = root && root.querySelector('.live-thread')
348
+ // a detached element loses its scroll position (WebKit), so remember it and put it back in after()
349
+ const ol = w && w.querySelector('.live-log')
350
+ kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
141
351
  if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
142
352
  },
143
353
  // After: put the old thread back in place of the new one and diff the new entries in.
@@ -145,24 +355,31 @@ export function createLiveKeeper() {
145
355
  const fresh = root && root.querySelector('.live-thread')
146
356
  if (!fresh) { kept.wrap = null; return null }
147
357
  const old = kept.wrap
148
- if (!old || old.dataset.sid !== fresh.dataset.sid) { kept.unseen = 0; const ol = fresh.querySelector('.live-log'); if (ol) ol.scrollTop = ol.scrollHeight; return { thread: fresh, follow: true, first: true } }
358
+ 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 } }
149
359
  const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
150
360
  fresh.replaceWith(old)
151
361
  if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
152
- const wasAtBottom = atBottom(oldOl)
362
+ if (kept.scroll) oldOl.scrollTop = kept.scroll.top
363
+ const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
364
+ const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
153
365
  const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
154
- let added = 0
366
+ let added = 0, older = 0
155
367
  let prev = null
368
+ let seenKept = false
156
369
  for (const li of [...newOl.children]) {
157
370
  const cur = have.get(li.dataset.key)
158
371
  have.delete(li.dataset.key)
159
- if (!cur) { added++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
160
- if (cur.dataset.sig !== li.dataset.sig) { cur.replaceWith(li); prev = li } else prev = cur
372
+ // added above everything already shown ("Show earlier"): older, not new
373
+ // Claude's "Working" bubble isn't news by itself; the answer that replaces it is
374
+ if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
375
+ if (!cur.dataset.older) seenKept = true
376
+ if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
161
377
  }
162
378
  for (const li of have.values()) li.remove()
163
379
  const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
164
380
  kept.unseen = d.unseen
165
381
  if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
382
+ else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
166
383
  return { thread: old, follow: d.follow, unseen: d.unseen }
167
384
  },
168
385
  seen() { kept.unseen = 0 },
@@ -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/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
+ }