@leftoff/cli 0.9.8-beta.11 → 0.9.8-beta.13
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 +3 -0
- package/bin/leftoff.js +302 -301
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +23 -0
- package/ui/app.js +46 -0
- package/ui/live-view.js +3 -2
- package/ui/update-bar.js +158 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.13",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.13' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.13",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -2076,6 +2076,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2076
2076
|
.lv-md pre code { background: none; border: 0; padding: 0; }
|
|
2077
2077
|
.lv-md a { color: var(--accent); text-decoration: underline; }
|
|
2078
2078
|
.lv-md blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--muted); }
|
|
2079
|
+
.lv-you .lv-md code, .lv-you .lv-md pre { background: color-mix(in srgb, var(--accent) 8%, var(--card)); border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); }
|
|
2079
2080
|
.lv-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
|
|
2080
2081
|
/* the status line under a message: Sent → Received → Working → Replied, with a spinner and the time */
|
|
2081
2082
|
.lv-status { margin-top: 6px; font-size: 12px; color: var(--muted); }
|
|
@@ -2586,3 +2587,25 @@ html.pv-open, html.pv-open body { overflow: hidden; }
|
|
|
2586
2587
|
/* a chip in the box opens its preview (its × and ↻ stay their own buttons) */
|
|
2587
2588
|
.att-open { display: flex; align-items: center; gap: inherit; min-width: 0; flex: 1 1 auto; min-height: 44px; padding: 0; margin: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 9px; }
|
|
2588
2589
|
.att-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2590
|
+
|
|
2591
|
+
/* "Leftoff updated · Refresh" (0.9.8, ui/update-bar.js): a pill at the top,
|
|
2592
|
+
above the header and the phone's bars (it doesn't move with nav-hide) and
|
|
2593
|
+
under menus, sheets and the previews. Never covers the bottom nav or the composer. */
|
|
2594
|
+
.updatebar { position: fixed; z-index: 35; left: 50%; top: calc(env(safe-area-inset-top) + 8px); transform: translate(-50%, -12px); opacity: 0;
|
|
2595
|
+
display: flex; align-items: center; gap: 4px; max-width: min(520px, calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right))); width: max-content;
|
|
2596
|
+
padding: 4px 4px 4px 14px; border-radius: 26px; background: var(--card2); color: var(--text); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow);
|
|
2597
|
+
font-size: 14px; line-height: 1.3; transition: opacity .2s ease, transform .2s ease; }
|
|
2598
|
+
.updatebar.in { opacity: 1; transform: translate(-50%, 0); }
|
|
2599
|
+
.updatebar[hidden] { display: none; }
|
|
2600
|
+
.updatebar .ub-icon { display: inline-flex; color: var(--accent); flex: none; }
|
|
2601
|
+
.updatebar .ub-icon svg { width: 16px; height: 16px; }
|
|
2602
|
+
.updatebar .ub-text { flex: 1 1 auto; min-width: 0; font-weight: 600; padding: 0 6px 0 4px; overflow-wrap: anywhere; }
|
|
2603
|
+
.updatebar.confirm .ub-text { font-weight: 500; font-size: 13px; color: var(--text2, var(--text)); }
|
|
2604
|
+
.updatebar .ub-go { flex: none; min-height: 44px; padding: 0 16px; border-radius: 22px; border: 0; background: var(--accent); color: var(--on-accent); font: inherit; font-weight: 700; cursor: pointer; white-space: nowrap; }
|
|
2605
|
+
.updatebar .ub-go:disabled { opacity: .7; cursor: default; }
|
|
2606
|
+
.updatebar .ub-x { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: none; color: var(--muted); cursor: pointer; }
|
|
2607
|
+
.updatebar .ub-x svg { width: 18px; height: 18px; }
|
|
2608
|
+
.updatebar .ub-x:hover { color: var(--text); background: var(--bg2); }
|
|
2609
|
+
.updatebar .ub-go:focus-visible, .updatebar .ub-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2610
|
+
@media (min-width: 700px) { .updatebar { left: calc(50% + var(--rail-w, 0px) / 2); } }
|
|
2611
|
+
@media (prefers-reduced-motion: reduce) { .updatebar { transition: none; } }
|
package/ui/app.js
CHANGED
|
@@ -17,6 +17,7 @@ import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (
|
|
|
17
17
|
import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
|
|
18
18
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
19
19
|
import { wireTips } from './tips.js'
|
|
20
|
+
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
20
21
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
21
22
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
22
23
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
@@ -34,6 +35,10 @@ const $app = document.getElementById('app')
|
|
|
34
35
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
35
36
|
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
36
37
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
38
|
+
// 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
|
|
39
|
+
const carried = takeCarry()
|
|
40
|
+
if (carried) Object.assign(state.drafts, carried.drafts)
|
|
41
|
+
let updateBar = null
|
|
37
42
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
38
43
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
39
44
|
|
|
@@ -3991,7 +3996,9 @@ async function refreshHealth() {
|
|
|
3991
3996
|
if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
|
|
3992
3997
|
paintMoreDots()
|
|
3993
3998
|
paintSettingsDots()
|
|
3999
|
+
if (updateBar) updateBar.check(h)
|
|
3994
4000
|
} catch (e) {
|
|
4001
|
+
if (updateBar) updateBar.check(null) // a failed check changes nothing
|
|
3995
4002
|
el.className = 'health down'
|
|
3996
4003
|
el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
|
|
3997
4004
|
el.title = el.querySelector('.label').textContent
|
|
@@ -5764,7 +5771,46 @@ document.addEventListener('pointerdown', (ev) => {
|
|
|
5764
5771
|
if (m && !m.contains(ev.target)) m.open = false
|
|
5765
5772
|
})
|
|
5766
5773
|
api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
|
|
5774
|
+
// 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
|
|
5775
|
+
// files than the ones this page runs. It never reloads by itself; Refresh
|
|
5776
|
+
// carries your drafts over and says first what wouldn't come along.
|
|
5777
|
+
updateBar = mountUpdateBar({
|
|
5778
|
+
icon,
|
|
5779
|
+
health: () => refreshHealth(),
|
|
5780
|
+
risks: () => {
|
|
5781
|
+
let attachments = 0, uploading = 0
|
|
5782
|
+
for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
|
|
5783
|
+
const f = document.activeElement
|
|
5784
|
+
const typing = !!(f && /^(INPUT|TEXTAREA)$/.test(f.tagName) && !f.dataset.draft && !f.readOnly && !/^(checkbox|radio|button|submit|range|color|file|search)$/.test(f.type) && !f.closest('.cmdk, #settingsForm') && String(f.value || '').trim())
|
|
5785
|
+
let unsavedSettings = false
|
|
5786
|
+
try { unsavedSettings = settingsChanged().length > 0 } catch {}
|
|
5787
|
+
return { attachments, uploading, unsavedSettings, typing }
|
|
5788
|
+
},
|
|
5789
|
+
carry: () => {
|
|
5790
|
+
const drafts = { ...state.drafts }
|
|
5791
|
+
for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
|
|
5792
|
+
const f = document.activeElement
|
|
5793
|
+
const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
|
|
5794
|
+
return { drafts, focus }
|
|
5795
|
+
},
|
|
5796
|
+
})
|
|
5797
|
+
// The box you were typing in, if you tabbed from it to the bar.
|
|
5798
|
+
let lastDraftFocus = null
|
|
5799
|
+
document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
|
|
5800
|
+
document.addEventListener('focusout', (ev) => { const t = ev.target; if (t && t.dataset && t.dataset.draft) lastDraftFocus = { key: t.dataset.draft, a: t.selectionStart || 0, b: t.selectionEnd || 0 } })
|
|
5801
|
+
// Back from Refresh: the box you were typing in has its text, and (with a
|
|
5802
|
+
// keyboard and mouse) the cursor where it was.
|
|
5803
|
+
function restoreCarriedFocus(tries = 50) {
|
|
5804
|
+
const f = carried && carried.focus
|
|
5805
|
+
if (!f || isTouchUi(window.matchMedia)) return
|
|
5806
|
+
if (document.activeElement && document.activeElement !== document.body) return // you've moved on
|
|
5807
|
+
const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
|
|
5808
|
+
if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
|
|
5809
|
+
t.focus({ preventScroll: true })
|
|
5810
|
+
try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
|
|
5811
|
+
}
|
|
5767
5812
|
refreshHealth().then(render).then(firstRunSetup)
|
|
5813
|
+
restoreCarriedFocus()
|
|
5768
5814
|
connect()
|
|
5769
5815
|
|
|
5770
5816
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
package/ui/live-view.js
CHANGED
|
@@ -208,8 +208,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
208
208
|
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
209
209
|
else if (m.from === 'you') {
|
|
210
210
|
const a = messageAttachments(m)
|
|
211
|
-
body = (a.text || !a.attachments.length ? '<div class="lv-text">' + esc(a.text) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
|
|
212
|
-
} else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
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>'
|
|
213
|
+
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
213
214
|
let foot = ''
|
|
214
215
|
if (m.from === 'claude') {
|
|
215
216
|
// what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
|
package/ui/update-bar.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// 0.9.8: "Leftoff updated · Refresh". An open board (a desktop tab, the
|
|
2
|
+
// iPhone Home Screen app) keeps running the code it loaded with until it's
|
|
3
|
+
// reloaded. The page knows the build it came with (index.html's
|
|
4
|
+
// <meta name="leftoff-ui">, stamped by the Mac that served it, src/ui-build.js)
|
|
5
|
+
// and every health check says what that Mac serves now (`ui`); when they differ
|
|
6
|
+
// a bar offers a one-tap reload. Never on first load, never on its own, and a
|
|
7
|
+
// failed health check changes nothing. The pure parts are unit-tested.
|
|
8
|
+
|
|
9
|
+
export const POLL_MS = 60 * 1000 // a health check at least this often while the page is visible
|
|
10
|
+
export const CARRY_KEY = 'leftoff.reload.carry' // drafts handed to the reloaded page
|
|
11
|
+
export const CARRY_MAX_AGE_MS = 2 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
// The build this page loaded with: { build, machine } from the meta tag, or null.
|
|
14
|
+
export function pageBuild(doc = typeof document !== 'undefined' ? document : null) {
|
|
15
|
+
const m = doc && doc.querySelector && doc.querySelector('meta[name="leftoff-ui"]')
|
|
16
|
+
const build = m && m.getAttribute('content')
|
|
17
|
+
return build ? { build, machine: m.getAttribute('data-machine') || '' } : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Should the bar show? loaded: { build, machine } (what this page runs);
|
|
21
|
+
// current: a health answer ({ ui, machine }), or null when the check failed;
|
|
22
|
+
// state: { showing, dismissed } (dismissed: the build whose bar was closed).
|
|
23
|
+
export function shouldShow(loaded, current, state = {}) {
|
|
24
|
+
const showing = !!state.showing
|
|
25
|
+
if (!current || typeof current.ui !== 'string' || !current.ui) return showing // no answer (or an older Mac): keep what's there
|
|
26
|
+
if (!loaded || !loaded.build) return false
|
|
27
|
+
// The board moved to another Mac (yours went to sleep): its files aren't an update of this page's.
|
|
28
|
+
if (loaded.machine && current.machine && loaded.machine !== current.machine) return showing
|
|
29
|
+
if (current.ui === loaded.build) return false
|
|
30
|
+
if (state.dismissed && state.dismissed === current.ui) return false
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What a reload would lose, in words; empty when nothing. Drafts (the Live
|
|
35
|
+
// composer, replies) are carried over, so they aren't here.
|
|
36
|
+
export function reloadRisks({ attachments = 0, uploading = 0, unsavedSettings = false, typing = false } = {}) {
|
|
37
|
+
const out = []
|
|
38
|
+
if (uploading > 0) out.push(uploading === 1 ? 'A file is still uploading' : uploading + ' files are still uploading')
|
|
39
|
+
else if (attachments > 0) out.push(attachments === 1 ? 'The attached file won’t carry over' : 'The ' + attachments + ' attached files won’t carry over')
|
|
40
|
+
if (unsavedSettings) out.push('Unsaved settings will be lost')
|
|
41
|
+
if (typing) out.push('What you’re typing will be lost')
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Drafts handed over a reload: written right before it, read once right after.
|
|
46
|
+
export function packCarry({ drafts = {}, focus = null, now = Date.now() } = {}) {
|
|
47
|
+
const d = {}
|
|
48
|
+
for (const [k, v] of Object.entries(drafts || {})) if (typeof v === 'string' && v.trim()) d[k] = v
|
|
49
|
+
return JSON.stringify({ at: now, drafts: d, focus: focus && d[focus.key] != null ? focus : null })
|
|
50
|
+
}
|
|
51
|
+
export function unpackCarry(raw, { now = Date.now(), maxAgeMs = CARRY_MAX_AGE_MS } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
const j = JSON.parse(raw || '')
|
|
54
|
+
if (!j || typeof j.at !== 'number' || now - j.at > maxAgeMs || now < j.at - 5000) return null
|
|
55
|
+
const drafts = {}
|
|
56
|
+
for (const [k, v] of Object.entries(j.drafts || {})) if (typeof v === 'string') drafts[k] = v
|
|
57
|
+
const f = j.focus && typeof j.focus.key === 'string' ? { key: j.focus.key, a: Number(j.focus.a) || 0, b: Number(j.focus.b) || 0 } : null
|
|
58
|
+
return { drafts, focus: f }
|
|
59
|
+
} catch { return null }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------------ the bar (DOM)
|
|
63
|
+
|
|
64
|
+
// mountUpdateBar({ health, risks, carry, icon }) -> { check(h), shown() }
|
|
65
|
+
// health(): a fresh health answer (the board's refreshHealth); risks(): the
|
|
66
|
+
// reloadRisks input; carry(): { drafts, focus } to hand over.
|
|
67
|
+
export function mountUpdateBar({ health, risks = () => ({}), carry = () => ({}), icon = () => '', pollMs } = {}) {
|
|
68
|
+
const every = Number(pollMs || (typeof window !== 'undefined' && window.LEFTOFF_UPDATE_POLL_MS)) || POLL_MS
|
|
69
|
+
let loaded = pageBuild()
|
|
70
|
+
const st = { showing: false, dismissed: null, confirming: false, reloading: false }
|
|
71
|
+
let lastCheckAt = Date.now()
|
|
72
|
+
let el = null
|
|
73
|
+
|
|
74
|
+
function build() {
|
|
75
|
+
el = document.createElement('div')
|
|
76
|
+
el.className = 'updatebar'
|
|
77
|
+
el.id = 'updateBar'
|
|
78
|
+
el.setAttribute('role', 'status')
|
|
79
|
+
el.setAttribute('aria-live', 'polite')
|
|
80
|
+
el.hidden = true
|
|
81
|
+
el.innerHTML = '<span class="ub-icon" aria-hidden="true">' + icon('refresh-cw') + '</span><span class="ub-text">Leftoff updated</span>' +
|
|
82
|
+
'<button type="button" class="ub-go">Refresh</button><button type="button" class="ub-x" aria-label="Dismiss" title="Not now">' + icon('x') + '</button>'
|
|
83
|
+
document.body.appendChild(el)
|
|
84
|
+
// a click doesn't pull focus out of the box you're typing in
|
|
85
|
+
for (const b of el.querySelectorAll('button')) b.addEventListener('mousedown', (ev) => ev.preventDefault())
|
|
86
|
+
el.querySelector('.ub-go').addEventListener('click', onGo)
|
|
87
|
+
el.querySelector('.ub-x').addEventListener('click', onDismiss)
|
|
88
|
+
}
|
|
89
|
+
function paint() {
|
|
90
|
+
if (!el) build()
|
|
91
|
+
const was = !el.hidden
|
|
92
|
+
el.hidden = !st.showing
|
|
93
|
+
el.classList.toggle('confirm', st.confirming)
|
|
94
|
+
const text = el.querySelector('.ub-text'), go = el.querySelector('.ub-go')
|
|
95
|
+
if (st.confirming) { text.textContent = st.confirming; go.textContent = 'Refresh anyway' } else { text.textContent = 'Leftoff updated'; go.textContent = st.reloading ? 'Refreshing…' : 'Refresh' }
|
|
96
|
+
go.disabled = !!st.reloading
|
|
97
|
+
if (st.showing && !was) requestAnimationFrame(() => el && el.classList.add('in'))
|
|
98
|
+
if (!st.showing) el.classList.remove('in')
|
|
99
|
+
document.documentElement.classList.toggle('has-updatebar', st.showing)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every health answer the board gets comes through here (its own poll, the
|
|
103
|
+
// event stream reopening after a restart, the page coming back).
|
|
104
|
+
function check(h) {
|
|
105
|
+
lastCheckAt = Date.now()
|
|
106
|
+
if (h && !loaded && typeof h.ui === 'string' && h.ui) loaded = { build: h.ui, machine: h.machine || '' } // a page with no stamp: its first answer is its build
|
|
107
|
+
const next = shouldShow(loaded, h || null, st)
|
|
108
|
+
if (next !== st.showing) { st.showing = next; st.confirming = false; paint() }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onDismiss() {
|
|
112
|
+
const h = lastHealth
|
|
113
|
+
st.dismissed = (h && h.ui) || st.dismissed || 'any'
|
|
114
|
+
st.showing = false; st.confirming = false
|
|
115
|
+
paint()
|
|
116
|
+
}
|
|
117
|
+
let lastHealth = null
|
|
118
|
+
function onHealth(h) { lastHealth = h; check(h) }
|
|
119
|
+
|
|
120
|
+
async function onGo() {
|
|
121
|
+
if (st.reloading) return
|
|
122
|
+
const words = reloadRisks(risks() || {})
|
|
123
|
+
if (words.length && !st.confirming) { st.confirming = words.join(' · '); paint(); return }
|
|
124
|
+
st.reloading = true; st.confirming = false; paint()
|
|
125
|
+
try { sessionStorage.setItem(CARRY_KEY, packCarry(carry() || {})) } catch {}
|
|
126
|
+
// A new service worker, if its file changed, before the page comes back.
|
|
127
|
+
try {
|
|
128
|
+
const reg = 'serviceWorker' in navigator && await navigator.serviceWorker.getRegistration()
|
|
129
|
+
if (reg) await Promise.race([reg.update(), new Promise((r) => setTimeout(r, 3000))])
|
|
130
|
+
} catch {}
|
|
131
|
+
location.reload()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A health check at least every `every` ms while visible, and at once when
|
|
135
|
+
// the page comes back (the Home Screen app resumes, a bfcache restore).
|
|
136
|
+
const due = () => document.visibilityState !== 'hidden' && Date.now() - lastCheckAt >= every - 250
|
|
137
|
+
const tick = () => { if (due()) { lastCheckAt = Date.now(); Promise.resolve(health()).catch(() => {}) } }
|
|
138
|
+
setInterval(tick, Math.min(every, 15000))
|
|
139
|
+
const wake = (ev) => {
|
|
140
|
+
if (document.visibilityState === 'hidden') return
|
|
141
|
+
if (ev && ev.type === 'pageshow' && !ev.persisted) return
|
|
142
|
+
if (Date.now() - lastCheckAt < 3000) return
|
|
143
|
+
lastCheckAt = Date.now(); Promise.resolve(health()).catch(() => {})
|
|
144
|
+
}
|
|
145
|
+
document.addEventListener('visibilitychange', wake)
|
|
146
|
+
window.addEventListener('pageshow', wake)
|
|
147
|
+
window.addEventListener('online', wake)
|
|
148
|
+
|
|
149
|
+
return { check: onHealth, shown: () => st.showing, loaded: () => loaded }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Right after a reload from the bar: the drafts it carried (once).
|
|
153
|
+
export function takeCarry(storage = typeof sessionStorage !== 'undefined' ? sessionStorage : null) {
|
|
154
|
+
if (!storage) return null
|
|
155
|
+
let raw = null
|
|
156
|
+
try { raw = storage.getItem(CARRY_KEY); if (raw != null) storage.removeItem(CARRY_KEY) } catch { return null }
|
|
157
|
+
return raw ? unpackCarry(raw) : null
|
|
158
|
+
}
|