@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/CHANGELOG.md +43 -2
- package/bin/leftoff.js +423 -392
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +24 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +492 -22
- package/ui/app.js +1200 -93
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/index.html +4 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +17 -9
- package/ui/live-view.js +229 -12
- package/ui/live-wake.js +96 -0
- package/ui/navhide.js +35 -0
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/sw.js +52 -5
- package/ui/tabs.js +216 -0
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
- package/ui/update-bar.js +158 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
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
|
-
|
|
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
|
-
|
|
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 =
|
|
224
|
+
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
107
225
|
} else if (m.state) {
|
|
108
|
-
foot = '<div class="lv-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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 },
|
package/ui/live-wake.js
ADDED
|
@@ -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
|
+
}
|