@leftoff/cli 0.9.8-beta.9 → 0.9.8
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 +39 -3
- package/bin/leftoff.js +405 -361
- package/docs/SECURITY.md +14 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +10 -0
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +441 -13
- package/ui/app.js +1082 -85
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/diff-view.js +278 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +2 -1
- package/ui/live-view.js +22 -5
- package/ui/live-wake.js +96 -0
- package/ui/machines.js +17 -0
- package/ui/navhide.js +35 -0
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/relay-client.js +14 -4
- package/ui/restore-gate.js +20 -0
- package/ui/tabs.js +9 -5
- 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/what-it-did.js +149 -0
package/ui/lightbox.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// A full-screen preview for images (0.9.8): a dark backdrop, the picture fit
|
|
2
|
+
// to the screen, pinch / wheel / double-tap zoom, a drag to pan when zoomed,
|
|
3
|
+
// a swipe (or ← →, or the edge arrows) between the pictures of one message,
|
|
4
|
+
// and Esc, ×, a tap on the backdrop or a swipe down to close. The pure parts
|
|
5
|
+
// first (unit-tested); the DOM part only runs in a browser.
|
|
6
|
+
|
|
7
|
+
export const LB_MIN = 1
|
|
8
|
+
export const LB_MAX = 4
|
|
9
|
+
export const LB_ZOOM = 2 // a double tap: fit ↔ 2x
|
|
10
|
+
export const LB_TAP_MOVE = 10 // px a tap may wander
|
|
11
|
+
export const LB_DOUBLE_MS = 300
|
|
12
|
+
export const LB_DOUBLE_DIST = 30
|
|
13
|
+
export const LB_SWIPE_MIN = 40 // px: shorter is never a swipe
|
|
14
|
+
export const LB_SWIPE_FRAC = 0.22 // of the width: a slow drag this far turns the page
|
|
15
|
+
export const LB_FLICK = 0.45 // px/ms: a quick flick past LB_SWIPE_MIN does too
|
|
16
|
+
export const LB_CLOSE_DIST = 110 // px down: close
|
|
17
|
+
export const LB_IDLE_MS = 2600 // the top bar fades after this long untouched
|
|
18
|
+
|
|
19
|
+
const num = (v, d = 0) => (Number.isFinite(v) ? v : d)
|
|
20
|
+
|
|
21
|
+
export function clampScale(s) { return Math.min(LB_MAX, Math.max(LB_MIN, num(s, LB_MIN))) }
|
|
22
|
+
|
|
23
|
+
// The picture's size fit inside the view (object-fit: contain).
|
|
24
|
+
export function fitRect(nw, nh, vw, vh) {
|
|
25
|
+
if (!(nw > 0 && nh > 0 && vw > 0 && vh > 0)) return { w: Math.max(0, num(vw)), h: Math.max(0, num(vh)) }
|
|
26
|
+
const k = Math.min(vw / nw, vh / nh)
|
|
27
|
+
return { w: nw * k, h: nh * k }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pan never shows past the picture's edge; at fit (or where the zoomed picture
|
|
31
|
+
// is still smaller than the view on an axis) it stays centred on that axis.
|
|
32
|
+
export function clampPan(st, fit, view) {
|
|
33
|
+
const s = clampScale(st.scale)
|
|
34
|
+
const mx = Math.max(0, (fit.w * s - view.w) / 2)
|
|
35
|
+
const my = Math.max(0, (fit.h * s - view.h) / 2)
|
|
36
|
+
const c = (v, m) => (m === 0 ? 0 : Math.min(m, Math.max(-m, num(v))))
|
|
37
|
+
return { scale: s, x: c(st.x, mx), y: c(st.y, my) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Zoom to `next` keeping the point (px, py) — measured from the view's centre —
|
|
41
|
+
// under the finger or the pointer.
|
|
42
|
+
export function zoomAt(st, next, px, py, fit, view) {
|
|
43
|
+
const s = clampScale(st.scale)
|
|
44
|
+
const n = clampScale(next)
|
|
45
|
+
const k = n / s
|
|
46
|
+
return clampPan({ scale: n, x: px - k * (px - num(st.x)), y: py - k * (py - num(st.y)) }, fit, view)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A double tap or double-click: zoomed → back to fit; at fit → 2x at that point.
|
|
50
|
+
export function toggleZoom(st, px, py, fit, view) {
|
|
51
|
+
if (clampScale(st.scale) > LB_MIN + 0.01) return { scale: LB_MIN, x: 0, y: 0 }
|
|
52
|
+
return zoomAt({ scale: LB_MIN, x: 0, y: 0 }, LB_ZOOM, px, py, fit, view)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A wheel notch → a zoom factor. ctrl+wheel is a trackpad pinch (small deltas).
|
|
56
|
+
export function wheelFactor(deltaY, { ctrl = false, lines = false } = {}) {
|
|
57
|
+
const dy = num(deltaY) * (lines ? 16 : 1)
|
|
58
|
+
return Math.exp(-dy * (ctrl ? 0.01 : 0.002))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Two fingers: the scale follows the ratio of their spread.
|
|
62
|
+
export function pinchScale(s0, d0, d) {
|
|
63
|
+
if (!(d0 > 0) || !(d > 0)) return clampScale(s0)
|
|
64
|
+
return clampScale(num(s0, 1) * (d / d0))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One step through the pictures: no wrap (the first has no previous, the last no next).
|
|
68
|
+
export function stepIndex(i, d, n) {
|
|
69
|
+
if (!(n > 0)) return 0
|
|
70
|
+
return Math.min(n - 1, Math.max(0, Math.trunc(num(i)) + Math.sign(num(d))))
|
|
71
|
+
}
|
|
72
|
+
export const hasPrev = (i, n) => n > 1 && i > 0
|
|
73
|
+
export const hasNext = (i, n) => n > 1 && i < n - 1
|
|
74
|
+
export const counterText = (i, n) => (n > 1 ? (i + 1) + ' of ' + n : '')
|
|
75
|
+
|
|
76
|
+
// Which way a one-finger drag is going, once it has gone far enough to say.
|
|
77
|
+
export function dragAxis(dx, dy) {
|
|
78
|
+
if (Math.hypot(dx, dy) < LB_TAP_MOVE) return null
|
|
79
|
+
return Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// What a finished one-finger drag does at fit: 'next', 'prev', 'close' or
|
|
83
|
+
// 'back' (spring back). Zoomed, a drag pans and decides nothing ('none').
|
|
84
|
+
export function swipeDecision({ dx = 0, dy = 0, dt = 1, scale = 1, vw = 390, index = 0, count = 1 } = {}) {
|
|
85
|
+
if (clampScale(scale) > LB_MIN + 0.01) return 'none'
|
|
86
|
+
const t = Math.max(1, num(dt, 1))
|
|
87
|
+
const ax = Math.abs(dx), ay = Math.abs(dy)
|
|
88
|
+
if (ax < LB_SWIPE_MIN && ay < LB_SWIPE_MIN) return 'back'
|
|
89
|
+
if (ax >= ay) {
|
|
90
|
+
const far = ax >= Math.max(LB_SWIPE_MIN, vw * LB_SWIPE_FRAC) || ax / t >= LB_FLICK
|
|
91
|
+
if (!far) return 'back'
|
|
92
|
+
if (dx < 0) return hasNext(index, count) ? 'next' : 'back'
|
|
93
|
+
return hasPrev(index, count) ? 'prev' : 'back'
|
|
94
|
+
}
|
|
95
|
+
if (dy <= 0) return 'back'
|
|
96
|
+
return dy >= LB_CLOSE_DIST || dy / t >= LB_FLICK ? 'close' : 'back'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// While a drag follows the finger: sideways past the ends it resists; up it
|
|
100
|
+
// resists; down fades the backdrop.
|
|
101
|
+
export function dragOffset(axis, dx, dy, { index = 0, count = 1 } = {}) {
|
|
102
|
+
if (axis === 'x') {
|
|
103
|
+
const edge = (dx > 0 && !hasPrev(index, count)) || (dx < 0 && !hasNext(index, count))
|
|
104
|
+
return { x: edge ? dx / 3 : dx, y: 0 }
|
|
105
|
+
}
|
|
106
|
+
if (axis === 'y') return { x: 0, y: dy > 0 ? dy : dy / 4 }
|
|
107
|
+
return { x: 0, y: 0 }
|
|
108
|
+
}
|
|
109
|
+
export function dragFade(dy, vh) { return dy > 0 && vh > 0 ? Math.max(0.25, 1 - dy / (vh * 0.7)) : 1 }
|
|
110
|
+
|
|
111
|
+
export function isDoubleTap(prev, cur) {
|
|
112
|
+
return !!(prev && cur && cur.t - prev.t <= LB_DOUBLE_MS && Math.hypot(cur.x - prev.x, cur.y - prev.y) <= LB_DOUBLE_DIST)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------- in the browser
|
|
116
|
+
|
|
117
|
+
const DL_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'
|
|
118
|
+
const X_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>'
|
|
119
|
+
const PREV_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 5-7 7 7 7"/></svg>'
|
|
120
|
+
const NEXT_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg>'
|
|
121
|
+
|
|
122
|
+
let ui = null
|
|
123
|
+
|
|
124
|
+
function build() {
|
|
125
|
+
const root = document.createElement('div')
|
|
126
|
+
root.className = 'lightbox'
|
|
127
|
+
root.id = 'lightbox'
|
|
128
|
+
root.hidden = true
|
|
129
|
+
root.tabIndex = -1
|
|
130
|
+
root.setAttribute('role', 'dialog')
|
|
131
|
+
root.setAttribute('aria-modal', 'true')
|
|
132
|
+
root.setAttribute('aria-label', 'Image preview')
|
|
133
|
+
root.innerHTML =
|
|
134
|
+
'<div class="lb-back" aria-hidden="true"></div>' +
|
|
135
|
+
'<div class="lb-stage"><img class="lb-img" alt="" draggable="false"><span class="lb-spin spin" aria-hidden="true" hidden></span><div class="lb-err" hidden></div></div>' +
|
|
136
|
+
'<div class="lb-bar">' +
|
|
137
|
+
'<div class="lb-meta"><span class="lb-name"></span><span class="lb-sub"><span class="lb-size"></span><span class="lb-count"></span></span></div>' +
|
|
138
|
+
'<a class="lb-btn lb-dl" role="button" aria-disabled="true">' + DL_IC + '<span class="lb-dl-label">Download</span></a>' +
|
|
139
|
+
'<button type="button" class="lb-btn lb-x" aria-label="Close" title="Close (Esc)">' + X_IC + '</button>' +
|
|
140
|
+
'</div>' +
|
|
141
|
+
'<button type="button" class="lb-nav lb-prev" aria-label="Previous image" title="Previous (←)">' + PREV_IC + '</button>' +
|
|
142
|
+
'<button type="button" class="lb-nav lb-next" aria-label="Next image" title="Next (→)">' + NEXT_IC + '</button>' +
|
|
143
|
+
'<div class="lb-live" aria-live="polite"></div>'
|
|
144
|
+
document.body.appendChild(root)
|
|
145
|
+
const q = (s) => root.querySelector(s)
|
|
146
|
+
ui = { root, back: q('.lb-back'), stage: q('.lb-stage'), img: q('.lb-img'), spin: q('.lb-spin'), err: q('.lb-err'), bar: q('.lb-bar'), name: q('.lb-name'), size: q('.lb-size'), count: q('.lb-count'), dl: q('.lb-dl'), x: q('.lb-x'), prev: q('.lb-prev'), next: q('.lb-next'), live: q('.lb-live'),
|
|
147
|
+
items: [], index: 0, st: { scale: 1, x: 0, y: 0 }, drag: { x: 0, y: 0 }, ptrs: new Map(), g: null, lastTap: null, tapT: 0, idleT: 0, token: 0, opts: null, closing: 0 }
|
|
148
|
+
wire()
|
|
149
|
+
return ui
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const reduced = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
153
|
+
const finePointer = () => !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches)
|
|
154
|
+
|
|
155
|
+
// The view: the stage less the safe areas (its padding), and where its centre sits.
|
|
156
|
+
function view() {
|
|
157
|
+
const cs = getComputedStyle(ui.stage)
|
|
158
|
+
const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0, pt = parseFloat(cs.paddingTop) || 0, pb = parseFloat(cs.paddingBottom) || 0
|
|
159
|
+
const W = ui.stage.clientWidth || window.innerWidth, H = ui.stage.clientHeight || window.innerHeight
|
|
160
|
+
return { w: Math.max(1, W - pl - pr), h: Math.max(1, H - pt - pb), cx: (pl - pr) / 2, cy: (pt - pb) / 2, W, H }
|
|
161
|
+
}
|
|
162
|
+
function natural() {
|
|
163
|
+
const it = ui.items[ui.index] || {}
|
|
164
|
+
const nw = ui.img.naturalWidth || it.w || 0, nh = ui.img.naturalHeight || it.h || 0
|
|
165
|
+
return { nw, nh }
|
|
166
|
+
}
|
|
167
|
+
function fit() { const v = view(), n = natural(); return fitRect(n.nw, n.nh, v.w, v.h) }
|
|
168
|
+
|
|
169
|
+
function apply({ anim = false } = {}) {
|
|
170
|
+
const v = view(), f = fit()
|
|
171
|
+
ui.img.style.width = f.w + 'px'
|
|
172
|
+
ui.img.style.height = f.h + 'px'
|
|
173
|
+
ui.root.classList.toggle('lb-anim', anim && !reduced())
|
|
174
|
+
const x = v.cx + ui.st.x + ui.drag.x, y = v.cy + ui.st.y + ui.drag.y
|
|
175
|
+
ui.img.style.transform = 'translate(-50%, -50%) translate3d(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px, 0) scale(' + ui.st.scale.toFixed(4) + ')'
|
|
176
|
+
ui.root.classList.toggle('lb-zoomed', ui.st.scale > LB_MIN + 0.01)
|
|
177
|
+
ui.root.dataset.scale = ui.st.scale.toFixed(3)
|
|
178
|
+
}
|
|
179
|
+
function setFade(o) { ui.back.style.opacity = String(o); ui.bar.style.opacity = o < 1 ? String(Math.max(0, (o - 0.25) / 0.75) * 0.6) : '' }
|
|
180
|
+
|
|
181
|
+
function showBar() {
|
|
182
|
+
ui.root.classList.remove('lb-idle')
|
|
183
|
+
clearTimeout(ui.idleT)
|
|
184
|
+
ui.idleT = setTimeout(() => { if (ui.root.hidden || ui.root.contains(document.activeElement) && document.activeElement.matches(':focus-visible') && document.activeElement !== ui.root) return; ui.root.classList.add('lb-idle') }, LB_IDLE_MS)
|
|
185
|
+
}
|
|
186
|
+
function toggleBar() { if (ui.root.classList.contains('lb-idle')) showBar(); else { clearTimeout(ui.idleT); ui.root.classList.add('lb-idle') } }
|
|
187
|
+
|
|
188
|
+
function paintMeta() {
|
|
189
|
+
const it = ui.items[ui.index] || {}, n = ui.items.length
|
|
190
|
+
ui.name.textContent = it.name || ''
|
|
191
|
+
ui.name.title = it.name || ''
|
|
192
|
+
ui.size.textContent = it.sizeLabel || ''
|
|
193
|
+
ui.count.textContent = counterText(ui.index, n)
|
|
194
|
+
ui.count.hidden = n < 2
|
|
195
|
+
ui.prev.hidden = !hasPrev(ui.index, n)
|
|
196
|
+
ui.next.hidden = !hasNext(ui.index, n)
|
|
197
|
+
ui.root.classList.toggle('lb-many', n > 1)
|
|
198
|
+
}
|
|
199
|
+
function setDownload(url) {
|
|
200
|
+
const it = ui.items[ui.index] || {}
|
|
201
|
+
if (url && it.download !== false) { ui.dl.href = url; ui.dl.setAttribute('download', it.name || 'image'); ui.dl.removeAttribute('aria-disabled'); ui.dl.removeAttribute('role'); ui.dl.hidden = false }
|
|
202
|
+
else { ui.dl.removeAttribute('href'); ui.dl.removeAttribute('download'); ui.dl.setAttribute('role', 'button'); ui.dl.setAttribute('aria-disabled', 'true'); ui.dl.hidden = it.download === false }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Show picture i: the thumbnail (blurred) at once, the full one when it lands.
|
|
206
|
+
function show(i, { from = 0 } = {}) {
|
|
207
|
+
const it = ui.items[i]
|
|
208
|
+
if (!it) return
|
|
209
|
+
ui.index = i
|
|
210
|
+
const token = ++ui.token
|
|
211
|
+
ui.st = { scale: 1, x: 0, y: 0 }
|
|
212
|
+
ui.drag = { x: 0, y: 0 }
|
|
213
|
+
ui.err.hidden = true
|
|
214
|
+
ui.img.alt = it.name || ''
|
|
215
|
+
paintMeta()
|
|
216
|
+
setDownload(it.url || null)
|
|
217
|
+
if (ui.items.length > 1) ui.live.textContent = (it.name || 'Image') + ', ' + counterText(i, ui.items.length)
|
|
218
|
+
const done = (url) => {
|
|
219
|
+
if (token !== ui.token) return
|
|
220
|
+
it.url = url
|
|
221
|
+
const pre = new Image()
|
|
222
|
+
pre.onload = () => {
|
|
223
|
+
if (token !== ui.token) return
|
|
224
|
+
it.w = pre.naturalWidth; it.h = pre.naturalHeight
|
|
225
|
+
ui.img.src = url
|
|
226
|
+
ui.img.classList.remove('lb-loading')
|
|
227
|
+
ui.spin.hidden = true
|
|
228
|
+
setDownload(url)
|
|
229
|
+
apply()
|
|
230
|
+
}
|
|
231
|
+
pre.onerror = () => { if (token !== ui.token) return; ui.spin.hidden = true; ui.img.classList.remove('lb-loading'); if (!it.thumb) ui.img.removeAttribute('src'); ui.err.textContent = 'Can’t show this picture here.'; ui.err.hidden = false }
|
|
232
|
+
pre.src = url
|
|
233
|
+
}
|
|
234
|
+
if (it.url) { ui.img.src = it.url; ui.img.classList.remove('lb-loading'); ui.spin.hidden = true }
|
|
235
|
+
else if (it.thumb) { ui.img.src = it.thumb; ui.img.classList.add('lb-loading'); ui.spin.hidden = false }
|
|
236
|
+
else { ui.img.removeAttribute('src'); ui.img.classList.remove('lb-loading'); ui.spin.hidden = false }
|
|
237
|
+
// slide in from the side it came from
|
|
238
|
+
if (from && !reduced()) { ui.drag = { x: from * Math.min(view().w, 600) * 0.35, y: 0 }; apply(); void ui.img.offsetWidth; ui.drag = { x: 0, y: 0 }; apply({ anim: true }) } else apply()
|
|
239
|
+
if (it.url) return
|
|
240
|
+
Promise.resolve().then(() => it.load()).then(done, (e) => {
|
|
241
|
+
if (token !== ui.token) return
|
|
242
|
+
ui.spin.hidden = true
|
|
243
|
+
ui.err.textContent = 'Couldn’t load it' + (e && e.message ? ': ' + e.message : '')
|
|
244
|
+
ui.err.hidden = false
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function go(d) {
|
|
249
|
+
const n = ui.items.length, to = stepIndex(ui.index, d, n)
|
|
250
|
+
if (to === ui.index) { ui.drag = { x: 0, y: 0 }; apply({ anim: true }); return false }
|
|
251
|
+
show(to, { from: d })
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function viewPoint(clientX, clientY) {
|
|
256
|
+
const r = ui.stage.getBoundingClientRect(), v = view()
|
|
257
|
+
return { x: clientX - r.left - r.width / 2 - v.cx, y: clientY - r.top - r.height / 2 - v.cy }
|
|
258
|
+
}
|
|
259
|
+
function zoomTo(scale, clientX, clientY, anim) {
|
|
260
|
+
const p = viewPoint(clientX, clientY)
|
|
261
|
+
ui.st = zoomAt(ui.st, scale, p.x, p.y, fit(), view())
|
|
262
|
+
apply({ anim })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function wire() {
|
|
266
|
+
const { root, stage, img } = ui
|
|
267
|
+
ui.x.addEventListener('click', () => close())
|
|
268
|
+
ui.prev.addEventListener('click', () => { go(-1); showBar() })
|
|
269
|
+
ui.next.addEventListener('click', () => { go(1); showBar() })
|
|
270
|
+
ui.dl.addEventListener('click', (e) => { if (!ui.dl.getAttribute('href')) e.preventDefault() })
|
|
271
|
+
img.addEventListener('load', () => { if (!root.hidden) apply() })
|
|
272
|
+
root.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') showBar() })
|
|
273
|
+
root.addEventListener('focusin', () => { if (document.activeElement !== root) showBar() })
|
|
274
|
+
window.addEventListener('resize', () => { if (root.hidden) return; ui.st = clampPan(ui.st, fit(), view()); apply() })
|
|
275
|
+
|
|
276
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
277
|
+
if (e.button > 0 || ui.closing) return
|
|
278
|
+
try { stage.setPointerCapture(e.pointerId) } catch {}
|
|
279
|
+
ui.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
|
280
|
+
if (ui.ptrs.size === 1) ui.g = { kind: 'one', x0: e.clientX, y0: e.clientY, t0: Date.now(), st0: { ...ui.st }, axis: null, moved: false, onImg: e.target === img, type: e.pointerType || 'mouse' }
|
|
281
|
+
else if (ui.ptrs.size === 2) {
|
|
282
|
+
const [a, b] = [...ui.ptrs.values()]
|
|
283
|
+
ui.drag = { x: 0, y: 0 }; setFade(1)
|
|
284
|
+
ui.g = { kind: 'pinch', d0: Math.hypot(a.x - b.x, a.y - b.y), st0: { ...ui.st }, m0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
stage.addEventListener('pointermove', (e) => {
|
|
288
|
+
if (!ui.ptrs.has(e.pointerId) || !ui.g) return
|
|
289
|
+
ui.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
|
290
|
+
const g = ui.g
|
|
291
|
+
if (g.kind === 'pinch' && ui.ptrs.size >= 2) {
|
|
292
|
+
const [a, b] = [...ui.ptrs.values()]
|
|
293
|
+
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
294
|
+
const p0 = viewPoint(g.m0.x, g.m0.y)
|
|
295
|
+
const s = pinchScale(g.st0.scale, g.d0, Math.hypot(a.x - b.x, a.y - b.y))
|
|
296
|
+
const z = zoomAt(g.st0, s, p0.x, p0.y, fit(), view())
|
|
297
|
+
ui.st = clampPan({ scale: z.scale, x: z.x + (m.x - g.m0.x), y: z.y + (m.y - g.m0.y) }, fit(), view())
|
|
298
|
+
apply()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (g.kind !== 'one') return
|
|
302
|
+
const dx = e.clientX - g.x0, dy = e.clientY - g.y0
|
|
303
|
+
if (!g.moved && Math.hypot(dx, dy) >= LB_TAP_MOVE) g.moved = true
|
|
304
|
+
if (!g.moved) return
|
|
305
|
+
if (g.st0.scale > LB_MIN + 0.01) { ui.st = clampPan({ scale: g.st0.scale, x: g.st0.x + dx, y: g.st0.y + dy }, fit(), view()); apply(); return }
|
|
306
|
+
if (g.type === 'mouse') return // a mouse at fit: the arrows and the keys turn the page
|
|
307
|
+
if (!g.axis) g.axis = dragAxis(dx, dy)
|
|
308
|
+
ui.drag = dragOffset(g.axis, dx, dy, { index: ui.index, count: ui.items.length })
|
|
309
|
+
setFade(g.axis === 'y' ? dragFade(ui.drag.y, view().H) : 1)
|
|
310
|
+
apply()
|
|
311
|
+
})
|
|
312
|
+
const end = (e, cancelled) => {
|
|
313
|
+
if (!ui.ptrs.has(e.pointerId)) return
|
|
314
|
+
ui.ptrs.delete(e.pointerId)
|
|
315
|
+
const g = ui.g
|
|
316
|
+
if (!g) return
|
|
317
|
+
if (g.kind === 'pinch') {
|
|
318
|
+
if (ui.ptrs.size) return
|
|
319
|
+
ui.g = null
|
|
320
|
+
if (ui.st.scale < LB_MIN + 0.03) ui.st = { scale: 1, x: 0, y: 0 }
|
|
321
|
+
ui.st = clampPan(ui.st, fit(), view()); apply({ anim: true })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
ui.g = null
|
|
325
|
+
if (cancelled) { ui.drag = { x: 0, y: 0 }; setFade(1); apply({ anim: true }); return }
|
|
326
|
+
if (!g.moved) return tap(e, g)
|
|
327
|
+
if (g.st0.scale > LB_MIN + 0.01 || g.type === 'mouse') return
|
|
328
|
+
const v = view()
|
|
329
|
+
const d = swipeDecision({ dx: e.clientX - g.x0, dy: e.clientY - g.y0, dt: Date.now() - g.t0, scale: ui.st.scale, vw: v.w, index: ui.index, count: ui.items.length })
|
|
330
|
+
if (d === 'close') return close({ swiped: true })
|
|
331
|
+
setFade(1)
|
|
332
|
+
if (d === 'next' || d === 'prev') { go(d === 'next' ? 1 : -1); return }
|
|
333
|
+
ui.drag = { x: 0, y: 0 }; apply({ anim: true })
|
|
334
|
+
}
|
|
335
|
+
stage.addEventListener('pointerup', (e) => end(e, false))
|
|
336
|
+
stage.addEventListener('pointercancel', (e) => end(e, true))
|
|
337
|
+
stage.addEventListener('lostpointercapture', (e) => { if (ui.ptrs.has(e.pointerId)) end(e, true) })
|
|
338
|
+
// Our own double-tap: stop the browser's double-tap zoom and dblclick text selection here.
|
|
339
|
+
stage.addEventListener('dblclick', (e) => e.preventDefault())
|
|
340
|
+
stage.addEventListener('wheel', (e) => {
|
|
341
|
+
e.preventDefault()
|
|
342
|
+
if (!e.ctrlKey && ui.st.scale > LB_MIN + 0.01 && !e.shiftKey) {
|
|
343
|
+
ui.st = clampPan({ scale: ui.st.scale, x: ui.st.x - e.deltaX, y: ui.st.y - e.deltaY }, fit(), view()); apply(); return
|
|
344
|
+
}
|
|
345
|
+
zoomTo(ui.st.scale * wheelFactor(e.deltaY, { ctrl: e.ctrlKey, lines: e.deltaMode === 1 }), e.clientX, e.clientY, false)
|
|
346
|
+
}, { passive: false })
|
|
347
|
+
// Safari's own pinch events: ours handles the pinch, the page stays put.
|
|
348
|
+
for (const t of ['gesturestart', 'gesturechange']) stage.addEventListener(t, (e) => e.preventDefault())
|
|
349
|
+
|
|
350
|
+
// Keys while open: Esc, ← →, and Tab kept inside.
|
|
351
|
+
window.addEventListener('keydown', (e) => {
|
|
352
|
+
if (root.hidden) return
|
|
353
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); return }
|
|
354
|
+
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); e.stopImmediatePropagation(); go(e.key === 'ArrowRight' ? 1 : -1); return }
|
|
355
|
+
if ((e.key === '+' || e.key === '=') && !e.metaKey && !e.ctrlKey) { e.preventDefault(); e.stopImmediatePropagation(); const r = ui.stage.getBoundingClientRect(); zoomTo(ui.st.scale * 1.5, r.left + r.width / 2, r.top + r.height / 2, true); return }
|
|
356
|
+
if ((e.key === '-' || e.key === '0') && !e.metaKey && !e.ctrlKey) { e.preventDefault(); e.stopImmediatePropagation(); ui.st = { scale: 1, x: 0, y: 0 }; apply({ anim: true }); return }
|
|
357
|
+
if (e.key === 'Tab') {
|
|
358
|
+
const f = [...root.querySelectorAll('a[href], button')].filter((b) => !b.hidden && !b.disabled && b.offsetParent !== null)
|
|
359
|
+
if (!f.length) { e.preventDefault(); root.focus(); return }
|
|
360
|
+
const i = f.indexOf(document.activeElement)
|
|
361
|
+
e.preventDefault(); e.stopImmediatePropagation()
|
|
362
|
+
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i < 0 || i >= f.length - 1 ? 0 : i + 1)
|
|
363
|
+
f[n].focus()
|
|
364
|
+
}
|
|
365
|
+
}, true)
|
|
366
|
+
// Focus never leaves the dialog while it's open.
|
|
367
|
+
document.addEventListener('focusin', (e) => { if (!root.hidden && !ui.closing && !root.contains(e.target)) ui.x.focus({ preventScroll: true }) })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function tap(e, g) {
|
|
371
|
+
const cur = { t: Date.now(), x: e.clientX, y: e.clientY }
|
|
372
|
+
if (isDoubleTap(ui.lastTap, cur)) {
|
|
373
|
+
clearTimeout(ui.tapT); ui.lastTap = null
|
|
374
|
+
const p = viewPoint(e.clientX, e.clientY)
|
|
375
|
+
ui.st = toggleZoom(ui.st, p.x, p.y, fit(), view())
|
|
376
|
+
apply({ anim: true })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
ui.lastTap = cur
|
|
380
|
+
const act = () => { if (!g.onImg && ui.st.scale <= LB_MIN + 0.01) close(); else if (g.type === 'mouse') showBar(); else toggleBar() }
|
|
381
|
+
clearTimeout(ui.tapT)
|
|
382
|
+
if (g.type === 'mouse' && !g.onImg) return act() // a click on the backdrop closes at once
|
|
383
|
+
if (g.type === 'mouse') { showBar(); return }
|
|
384
|
+
ui.tapT = setTimeout(act, LB_DOUBLE_MS) // a touch waits to see if a second tap follows
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// opts: { items: [{ name, sizeLabel, thumb, load: () => Promise<url>, w, h, el }],
|
|
388
|
+
// index, from (the thumbnail tapped), refocus: () => element to focus after }
|
|
389
|
+
export function openLightbox(opts) {
|
|
390
|
+
if (!ui) build()
|
|
391
|
+
const { root } = ui
|
|
392
|
+
clearTimeout(ui.closing); ui.closing = 0
|
|
393
|
+
ui.opts = opts
|
|
394
|
+
ui.items = (opts.items || []).map((it) => ({ ...it }))
|
|
395
|
+
ui.lastTap = null; ui.ptrs.clear(); ui.g = null
|
|
396
|
+
root.classList.remove('lb-out', 'lb-in', 'lb-idle')
|
|
397
|
+
root.hidden = false
|
|
398
|
+
document.documentElement.classList.add('lb-open')
|
|
399
|
+
setFade(1)
|
|
400
|
+
const i = Math.max(0, Math.min(ui.items.length - 1, opts.index || 0))
|
|
401
|
+
show(i)
|
|
402
|
+
root.classList.toggle('lb-fine', finePointer())
|
|
403
|
+
// grow out of the thumbnail when there is one on screen; otherwise a quick fade
|
|
404
|
+
const el = opts.from
|
|
405
|
+
const r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null
|
|
406
|
+
const f = fit(), v = view()
|
|
407
|
+
if (!reduced()) {
|
|
408
|
+
if (r && r.width > 4 && r.height > 4 && f.w > 0 && r.bottom > 0 && r.top < v.H) {
|
|
409
|
+
const k = Math.max(r.width / f.w, r.height / f.h)
|
|
410
|
+
const sr = ui.stage.getBoundingClientRect()
|
|
411
|
+
const save = ui.st
|
|
412
|
+
ui.st = { scale: k, x: r.left + r.width / 2 - (sr.left + sr.width / 2) - v.cx, y: r.top + r.height / 2 - (sr.top + sr.height / 2) - v.cy }
|
|
413
|
+
apply()
|
|
414
|
+
ui.root.dataset.scale = '1.000'
|
|
415
|
+
void ui.img.offsetWidth
|
|
416
|
+
ui.st = save
|
|
417
|
+
}
|
|
418
|
+
void root.offsetWidth
|
|
419
|
+
root.classList.add('lb-in')
|
|
420
|
+
apply({ anim: true })
|
|
421
|
+
} else { root.classList.add('lb-in'); apply() }
|
|
422
|
+
ui.x.focus({ preventScroll: true })
|
|
423
|
+
showBar()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function closeLightbox(o) { if (ui && !ui.root.hidden) close(o) }
|
|
427
|
+
export function lightboxOpen() { return !!(ui && !ui.root.hidden && !ui.closing) }
|
|
428
|
+
|
|
429
|
+
function close({ swiped = false } = {}) {
|
|
430
|
+
if (!ui || ui.root.hidden || ui.closing) return
|
|
431
|
+
const { root } = ui
|
|
432
|
+
clearTimeout(ui.tapT); clearTimeout(ui.idleT)
|
|
433
|
+
const opts = ui.opts || {}
|
|
434
|
+
const finish = () => {
|
|
435
|
+
ui.closing = 0
|
|
436
|
+
root.hidden = true
|
|
437
|
+
root.classList.remove('lb-out', 'lb-in', 'lb-anim', 'lb-zoomed', 'lb-idle')
|
|
438
|
+
ui.img.removeAttribute('src'); ui.token++
|
|
439
|
+
ui.items = []; ui.opts = null
|
|
440
|
+
setFade(1)
|
|
441
|
+
document.documentElement.classList.remove('lb-open')
|
|
442
|
+
let t = null
|
|
443
|
+
try { t = opts.refocus ? opts.refocus() : null } catch {}
|
|
444
|
+
if (!t || !t.isConnected) t = opts.from && opts.from.isConnected ? opts.from : null
|
|
445
|
+
if (t) t.focus({ preventScroll: true })
|
|
446
|
+
if (opts.onClose) try { opts.onClose() } catch {}
|
|
447
|
+
}
|
|
448
|
+
if (reduced()) return finish()
|
|
449
|
+
if (swiped) { ui.drag = { x: 0, y: ui.drag.y + view().H * 0.25 }; apply({ anim: true }) }
|
|
450
|
+
root.classList.add('lb-out')
|
|
451
|
+
ui.closing = setTimeout(finish, 170)
|
|
452
|
+
}
|
package/ui/live-status.js
CHANGED
|
@@ -38,7 +38,8 @@ export function stepStatus(st, ev = {}) {
|
|
|
38
38
|
case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; s.waiting = null; break
|
|
39
39
|
case 'tool_done': {
|
|
40
40
|
const name = clip(ev.tool || (s.tool && s.tool.name), 40)
|
|
41
|
-
|
|
41
|
+
// 0.9.8: the tool call's id (from the transcript) lets the board open its output
|
|
42
|
+
if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 300), asked: !!ev.asked, ...(typeof ev.id === 'string' && ev.id && ev.id.length <= 120 ? { id: ev.id } : {}) })
|
|
42
43
|
if (s.ran.length > 20) s.ran = s.ran.slice(-20)
|
|
43
44
|
s.tool = null; s.waiting = null
|
|
44
45
|
break
|
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')
|
|
@@ -191,11 +192,24 @@ function timeHtml(iso, { esc, now }) {
|
|
|
191
192
|
const full = new Date(Date.parse(iso)).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
192
193
|
return '<time class="faint" datetime="' + esc(new Date(Date.parse(iso)).toISOString()) + '" title="' + esc(full) + '">' + esc(label) + '</time>'
|
|
193
194
|
}
|
|
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 }) {
|
|
195
205
|
const who = senderLabel(m)
|
|
196
206
|
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
197
207
|
let body
|
|
198
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>'
|
|
199
213
|
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
200
214
|
let foot = ''
|
|
201
215
|
if (m.from === 'claude') {
|
|
@@ -235,7 +249,10 @@ export function midClip(s, n = 56) {
|
|
|
235
249
|
const head = Math.ceil((n - 1) * 0.55)
|
|
236
250
|
return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
|
|
237
251
|
}
|
|
238
|
-
const toolLine = (name, detail, { esc, cls = ''
|
|
252
|
+
const toolLine = (name, detail, { esc, cls = '', id = null }) => id
|
|
253
|
+
// 0.9.8 What it did: a tool that finished is a button that opens its output
|
|
254
|
+
? '<button type="button" class="lv-tool mono' + (cls ? ' ' + cls : '') + ' lv-tool-open" data-action="lv-tool-open" data-tool="' + esc(id) + '" data-label="' + esc(name + (detail ? ' · ' + detail : '')) + '" aria-label="' + esc('Show output: ' + name + (detail ? ' · ' + detail : '')) + '">' + esc(name) + (detail ? ' · ' + esc(midClip(detail)) : '') + '</button>'
|
|
255
|
+
: '<div class="lv-tool mono' + (cls ? ' ' + cls : '') + '"' + (detail ? ' title="' + esc(name + ' · ' + detail) + '"' : '') + '>' + esc(name) + (detail ? ' · ' + esc(midClip(detail)) : '') + '</div>'
|
|
239
256
|
|
|
240
257
|
// The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
|
|
241
258
|
export function workingHtml(st, { esc, now = Date.now() }) {
|
|
@@ -249,7 +266,7 @@ export function workingHtml(st, { esc, now = Date.now() }) {
|
|
|
249
266
|
else label = 'Working'
|
|
250
267
|
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>'
|
|
251
268
|
const recent = (st.ran || []).slice(-3)
|
|
252
|
-
for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
|
|
269
|
+
for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' }) // compact while it works; the folded list after the turn opens each one
|
|
253
270
|
if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
|
|
254
271
|
else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
|
|
255
272
|
return html + '</div>'
|
|
@@ -263,7 +280,7 @@ export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
|
|
|
263
280
|
const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
|
|
264
281
|
const v = statusView(st, now)
|
|
265
282
|
const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
|
|
266
|
-
return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
|
|
283
|
+
return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc, id: r.id || null })).join('') + '</details>'
|
|
267
284
|
}
|
|
268
285
|
|
|
269
286
|
// Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
|
|
@@ -308,7 +325,7 @@ export { ranWithoutAsking }
|
|
|
308
325
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
309
326
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
310
327
|
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) }
|
|
311
|
-
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.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
328
|
+
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]) }
|
|
312
329
|
|
|
313
330
|
// ------------------------------------------------------------ the DOM keeper
|
|
314
331
|
|
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/machines.js
CHANGED
|
@@ -41,3 +41,20 @@ export function machineEmojis(names, chosen = {}) {
|
|
|
41
41
|
for (const n of all) if (!out[n]) { out[n] = defaultEmoji(n, taken); taken.add(out[n]) }
|
|
42
42
|
return out
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
// 0.9.8: the board's Machine filter is saved in the browser (cp.machine). A
|
|
46
|
+
// filter left on (picked once to find another Mac's session) quietly hid every
|
|
47
|
+
// other Mac's sessions, and with them that Mac's space tab, on that browser
|
|
48
|
+
// only. A saved filter is used only while the board still knows that Mac (or
|
|
49
|
+
// agent); '' = every Mac.
|
|
50
|
+
export function boardMachineFilter(saved, { machines = [], agents = [] } = {}) {
|
|
51
|
+
const m = String(saved || '')
|
|
52
|
+
if (!m) return ''
|
|
53
|
+
if (!machines.length && !agents.length) return m // the board hasn't heard yet
|
|
54
|
+
return machines.includes(m) || agents.includes(m) ? m : ''
|
|
55
|
+
}
|
|
56
|
+
// The line a filtered board shows: whose sessions these are.
|
|
57
|
+
export function machineOnlyLabel(m, { machines = [], agents = [] } = {}) {
|
|
58
|
+
if (!m) return ''
|
|
59
|
+
return agents.includes(m) && !machines.includes(m) ? 'Only ' + m + '’s items' : 'Only ' + m + '’s sessions'
|
|
60
|
+
}
|