@leftoff/cli 0.9.8-beta.5 → 0.9.8-beta.7
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 +4 -0
- package/bin/leftoff.js +320 -318
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +22 -0
- package/ui/app.js +11 -2
- package/ui/index.html +1 -0
- package/ui/live-status.js +11 -4
- package/ui/live-view.js +60 -4
- package/ui/push.js +140 -0
- package/ui/sw.js +52 -5
package/docs/peer-protocol.md
CHANGED
|
@@ -111,6 +111,8 @@ Only Macs registered for that user can connect: the first with `leftoff relay re
|
|
|
111
111
|
|
|
112
112
|
Every few seconds (`peers.pollMs`, default 4 s) each daemon asks every machine that accepts for `needs.list`, so another Mac's permission prompts and questions show on this board with a ⌂ machine chip. Answer, reply and Go on another machine's session go out as envelopes. When that isn't possible, the button is grey and says why: not allowed on that machine, unreachable, key changed, no key yet (that Mac needs 0.5.2), or no route. Answering from a phone keeps its own rules (Settings → Privacy & security), and Go stays a local-browser action.
|
|
113
113
|
|
|
114
|
+
Phone notifications (0.9.8) ride the same envelopes: `push.sync` carries the Web Push subscriptions a browser turned on (endpoint, keys, a device label) with the VAPID keys they were made with, plus 30-day removal tombstones; each side merges (newer word per endpoint wins) and answers with its own, so every Mac can push for its own sessions and only those. It is answered even when a Mac doesn't allow incoming commands, because it runs nothing there. Kept on each Mac in `~/.config/checkpoints/push/` (0600), never in the synced folder. Shared when a device subscribes or unsubscribes, at start, and every 20 minutes.
|
|
115
|
+
|
|
114
116
|
## Pairing through Syncthing (0.5.3)
|
|
115
117
|
|
|
116
118
|
Before two Macs share a folder they have no channel to each other, so pairing rides on Syncthing itself. Leftoff never opens a port for it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.7",
|
|
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.7' // 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.7",
|
|
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
|
@@ -2038,6 +2038,13 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2038
2038
|
|
|
2039
2039
|
/* 0.9.8 Live link: the Live badge, and the session page's message box with its delivery states */
|
|
2040
2040
|
.pill.live-pill { color: var(--green); background: var(--tint-green); font-weight: 600; } /* not .live: that class is the open/closed dot */
|
|
2041
|
+
/* Its dot pulses while Claude is running a turn (a soft ring growing out of it); idle, it stays still. The text doesn't blink. */
|
|
2042
|
+
.pill.live-pill.working::before { animation: live-pulse 1.6s ease-in-out infinite; }
|
|
2043
|
+
@keyframes live-pulse {
|
|
2044
|
+
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 55%, transparent); }
|
|
2045
|
+
80%, 100% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--green) 0%, transparent); }
|
|
2046
|
+
}
|
|
2047
|
+
@media (prefers-reduced-motion: reduce) { .pill.live-pill.working::before, .s-active::before { animation: none; } }
|
|
2041
2048
|
.live-box { margin: 16px 0 18px; border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line)); background: linear-gradient(180deg, var(--tint-green), transparent 70%), var(--card); border-radius: var(--radius); padding: 12px 14px 14px; }
|
|
2042
2049
|
.live-box.off { border-color: var(--line); background: var(--card); }
|
|
2043
2050
|
.live-box.off p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
|
|
@@ -2306,3 +2313,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2306
2313
|
.card > .actions-row > .btn.icon-btn { width: 44px; }
|
|
2307
2314
|
.card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
|
|
2308
2315
|
}
|
|
2316
|
+
|
|
2317
|
+
/* Phone notifications (0.9.8): the one per-device button, in More and Settings → General */
|
|
2318
|
+
.push-slot[hidden] { display: none; }
|
|
2319
|
+
.push-box { display: flex; gap: 10px; align-items: flex-start; }
|
|
2320
|
+
.push-box > .ic { width: 20px; height: 20px; flex: none; margin-top: 2px; color: var(--muted); }
|
|
2321
|
+
.push-main { min-width: 0; flex: 1; }
|
|
2322
|
+
.push-line { margin: 0; font-size: 14px; line-height: 1.4; color: var(--text2); }
|
|
2323
|
+
.push-line strong { color: var(--text); font-weight: 600; }
|
|
2324
|
+
.push-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
2325
|
+
.push-acts .btn { min-height: 44px; padding: 0 14px; font-size: 14px; white-space: nowrap; justify-content: center; }
|
|
2326
|
+
.push-note { margin: 8px 0 0; font-size: 13px; line-height: 1.4; color: var(--muted); }
|
|
2327
|
+
.push-note.bad { color: var(--red, #c0392b); }
|
|
2328
|
+
.moremenu .push-slot { border-top: 1px solid var(--line); margin-top: 6px; padding: 12px 8px 6px; }
|
|
2329
|
+
.moremenu:has(.push-slot:not([hidden])) { width: 270px; }
|
|
2330
|
+
.form .push-slot { grid-column: 1 / -1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card2, transparent); margin-top: 4px; }
|
package/ui/app.js
CHANGED
|
@@ -19,10 +19,13 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
19
19
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
20
|
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
21
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
22
|
-
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView } from './live-view.js' // 0.9.8 Live link polish
|
|
22
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses } from './live-view.js' // 0.9.8 Live link polish
|
|
23
23
|
import { statusView, elapsedText } from './live-status.js'
|
|
24
|
+
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
24
25
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
25
26
|
const $app = document.getElementById('app')
|
|
27
|
+
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
28
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
26
29
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
27
30
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
28
31
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
@@ -119,7 +122,8 @@ function copiedMark(btn) {
|
|
|
119
122
|
}
|
|
120
123
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
121
124
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
122
|
-
|
|
125
|
+
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
126
|
+
const liveBadge = (s) => liveBadgeHtml(s)
|
|
123
127
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
124
128
|
const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
|
|
125
129
|
|
|
@@ -2613,6 +2617,7 @@ async function viewSettings() {
|
|
|
2613
2617
|
general: '<div class="section">Needs you</div>' +
|
|
2614
2618
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
2615
2619
|
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
2620
|
+
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
2616
2621
|
'<div class="section">Appearance</div>' +
|
|
2617
2622
|
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
2618
2623
|
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
@@ -3530,6 +3535,7 @@ function openMore() {
|
|
|
3530
3535
|
if (!m) return
|
|
3531
3536
|
closeQuickLook(); toggleStatus(false)
|
|
3532
3537
|
paintMoreDots()
|
|
3538
|
+
paintPush()
|
|
3533
3539
|
m.hidden = false
|
|
3534
3540
|
if (b) b.setAttribute('aria-expanded', 'true')
|
|
3535
3541
|
const first = m.querySelector('a.on') || m.querySelector('a')
|
|
@@ -4970,3 +4976,6 @@ connect()
|
|
|
4970
4976
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
|
4971
4977
|
// when the Mac can't be reached; it caches no API data and no board pages.
|
|
4972
4978
|
if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
|
|
4979
|
+
// 0.9.8: phone notifications. The one per-device button (More on a phone,
|
|
4980
|
+
// Settings → General everywhere); the service worker shows the pushes.
|
|
4981
|
+
mountPush({ api })
|
package/ui/index.html
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
<a href="#/digest" data-route="digest" role="menuitem"><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="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
|
|
47
47
|
<a href="#/whats-new" data-route="whats-new" role="menuitem"><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="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span>What’s new</span><span class="newdot" hidden></span></a>
|
|
48
48
|
<a href="#/settings" data-route="settings" role="menuitem"><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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span>Settings</span><span class="topdot" hidden></span></a>
|
|
49
|
+
<div class="push-slot" data-push-slot role="group" aria-label="Notifications" hidden></div>
|
|
49
50
|
</div>
|
|
50
51
|
<div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
|
|
51
52
|
<div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
|
package/ui/live-status.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// └─► failed └─► stopped (the turn ended with no answer)
|
|
13
13
|
// received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
|
|
14
14
|
// something happens again; replied and failed are final.
|
|
15
|
+
// A message that arrived while Claude was already working on another one is
|
|
16
|
+
// folded into that turn (folded: the id of the message that owns the turn):
|
|
17
|
+
// it has no Working bubble of its own, and when the turn ends it is
|
|
18
|
+
// "Answered with the reply above".
|
|
15
19
|
|
|
16
20
|
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
17
21
|
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
@@ -22,7 +26,7 @@ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, '
|
|
|
22
26
|
export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
|
|
23
27
|
|
|
24
28
|
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
25
|
-
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended
|
|
29
|
+
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
|
|
26
30
|
export function stepStatus(st, ev = {}) {
|
|
27
31
|
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
28
32
|
const at = ev.at || s.last
|
|
@@ -43,6 +47,8 @@ export function stepStatus(st, ev = {}) {
|
|
|
43
47
|
case 'ask_done': s.waiting = null; break
|
|
44
48
|
case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
|
|
45
49
|
case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
|
|
50
|
+
case 'folded': if (s.phase === 'sent' || s.phase === 'received') s.phase = 'working'; s.folded = ev.owner || true; s.tool = null; s.waiting = null; break
|
|
51
|
+
case 'answered': s.phase = 'replied'; s.folded = ev.owner || s.folded || true; s.tool = null; s.waiting = null; s.endedAt = at; break // folded: the reply above is its answer
|
|
46
52
|
case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
|
|
47
53
|
default: return s
|
|
48
54
|
}
|
|
@@ -53,22 +59,23 @@ export function stepStatus(st, ev = {}) {
|
|
|
53
59
|
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
54
60
|
export function statusView(st, now = Date.now()) {
|
|
55
61
|
if (!st) return null
|
|
56
|
-
const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
62
|
+
const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
57
63
|
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
58
64
|
let label
|
|
59
65
|
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
60
66
|
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
67
|
+
else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
|
|
61
68
|
else if (st.phase === 'working') {
|
|
62
69
|
if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
|
|
63
70
|
else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
|
|
64
71
|
else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
|
|
65
|
-
} else if (st.phase === 'replied') label = 'Replied'
|
|
72
|
+
} else if (st.phase === 'replied') label = st.folded ? 'Answered with the reply above' : 'Replied'
|
|
66
73
|
else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
|
|
67
74
|
else label = 'Didn’t arrive'
|
|
68
75
|
const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
|
|
69
76
|
const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
|
|
70
77
|
.map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
|
|
71
|
-
const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
78
|
+
const spin = !quiet && !st.folded && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
72
79
|
const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
|
|
73
80
|
return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
|
|
74
81
|
}
|
package/ui/live-view.js
CHANGED
|
@@ -65,6 +65,44 @@ export function cardPills(s) {
|
|
|
65
65
|
return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Is Claude running a turn in this live session right now? The Live pill's dot
|
|
69
|
+
// pulses while it is. The same signals the board already shows: the session's
|
|
70
|
+
// own status (a turn typed in the terminal, which the hooks see start), or the
|
|
71
|
+
// board message whose turn is still going, read the way its Working bubble
|
|
72
|
+
// reads it (spinning: sent, received or working; not waiting for your OK, not
|
|
73
|
+
// gone quiet). Idle, waiting on you, or another Mac's daemon that doesn't say:
|
|
74
|
+
// a steady dot.
|
|
75
|
+
export function liveWorking(s, now = Date.now()) {
|
|
76
|
+
if (!cardPills(s).live) return false
|
|
77
|
+
if (s.status === 'active') return true
|
|
78
|
+
const t = s.liveLink && s.liveLink.turn
|
|
79
|
+
if (!t || !t.phase) return false
|
|
80
|
+
const v = statusView(t, now)
|
|
81
|
+
return !!(v && v.spin)
|
|
82
|
+
}
|
|
83
|
+
const LIVE_TIP = 'Live link: send this session messages from the board or your phone'
|
|
84
|
+
export function liveBadgeHtml(s, now = Date.now()) {
|
|
85
|
+
if (!cardPills(s).live) return ''
|
|
86
|
+
const w = liveWorking(s, now)
|
|
87
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
91
|
+
// CSS pulse each time: a visible hitch every second or so while a session
|
|
92
|
+
// works. Pinning every pulse to the page's own clock (start time 0) makes a
|
|
93
|
+
// fresh element pick up exactly where the one it replaced was, and keeps the
|
|
94
|
+
// dots on the page beating together. Reduced motion has no animations to pin.
|
|
95
|
+
export const PULSE_NAMES = new Set(['live-pulse', 'pulse', 'needpulse'])
|
|
96
|
+
export function lockPulses(doc) {
|
|
97
|
+
if (!doc || typeof doc.getAnimations !== 'function') return 0
|
|
98
|
+
let n = 0
|
|
99
|
+
for (const a of doc.getAnimations()) {
|
|
100
|
+
if (!PULSE_NAMES.has(a.animationName) || a.startTime === 0) continue
|
|
101
|
+
try { a.startTime = 0; n++ } catch {}
|
|
102
|
+
}
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
// The "N empty sessions" fold never takes a live session.
|
|
69
107
|
export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
|
|
70
108
|
|
|
@@ -138,6 +176,21 @@ export function earlierHtml(n, { esc }) {
|
|
|
138
176
|
if (!(n > 0)) return ''
|
|
139
177
|
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>'
|
|
140
178
|
}
|
|
179
|
+
// A message's time is when it was sent (a clock time today, the date before):
|
|
180
|
+
// a relative "just now" drawn once stays "just now" while the bubble sits there.
|
|
181
|
+
export function sentLabel(iso, now = Date.now()) {
|
|
182
|
+
const t = iso ? Date.parse(iso) : NaN
|
|
183
|
+
if (!Number.isFinite(t)) return ''
|
|
184
|
+
const d = new Date(t), n = new Date(now)
|
|
185
|
+
const today = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
|
|
186
|
+
return today ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
187
|
+
}
|
|
188
|
+
function timeHtml(iso, { esc, now }) {
|
|
189
|
+
const label = sentLabel(iso, now)
|
|
190
|
+
if (!label) return ''
|
|
191
|
+
const full = new Date(Date.parse(iso)).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
192
|
+
return '<time class="faint" datetime="' + esc(new Date(Date.parse(iso)).toISOString()) + '" title="' + esc(full) + '">' + esc(label) + '</time>'
|
|
193
|
+
}
|
|
141
194
|
export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
|
|
142
195
|
const who = senderLabel(m)
|
|
143
196
|
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
@@ -159,7 +212,7 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null })
|
|
|
159
212
|
foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
|
|
160
213
|
}
|
|
161
214
|
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
|
|
162
|
-
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) + '
|
|
215
|
+
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>'
|
|
163
216
|
}
|
|
164
217
|
|
|
165
218
|
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
@@ -170,6 +223,8 @@ export function deliveryHtml(st, { esc, error = null }) {
|
|
|
170
223
|
if (!st) return ''
|
|
171
224
|
if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
|
|
172
225
|
const got = st.phase !== 'sent'
|
|
226
|
+
// folded into a turn Claude was already on: the reply above answers it too, no Working bubble of its own
|
|
227
|
+
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>'
|
|
173
228
|
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>'
|
|
174
229
|
}
|
|
175
230
|
|
|
@@ -217,8 +272,9 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
|
|
|
217
272
|
const key = 'ans_' + you.id + ':claude'
|
|
218
273
|
let inner
|
|
219
274
|
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 })
|
|
275
|
+
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
|
|
220
276
|
else inner = workingHtml(st, { esc, now })
|
|
221
|
-
return '<li class="lv lv-claude lv-pending' + (st.phase
|
|
277
|
+
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>'
|
|
222
278
|
}
|
|
223
279
|
|
|
224
280
|
// The whole thread: each message you sent, then Claude's side of that turn.
|
|
@@ -230,7 +286,7 @@ export function threadHtml(msgs, opts) {
|
|
|
230
286
|
for (const m of list) {
|
|
231
287
|
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 }
|
|
232
288
|
html += entryHtml(m, opts)
|
|
233
|
-
if (m.status && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
|
|
289
|
+
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
|
|
234
290
|
}
|
|
235
291
|
return html
|
|
236
292
|
}
|
|
@@ -252,7 +308,7 @@ export { ranWithoutAsking }
|
|
|
252
308
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
253
309
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
254
310
|
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) }
|
|
255
|
-
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]) }
|
|
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]) }
|
|
256
312
|
|
|
257
313
|
// ------------------------------------------------------------ the DOM keeper
|
|
258
314
|
|
package/ui/push.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Phone notifications (0.9.8): the one per-device button. No setting: the
|
|
2
|
+
// button and the browser's own permission prompt are the opt-in, and the same
|
|
3
|
+
// button turns them off for this device. The Mac that owns a session sends
|
|
4
|
+
// the push (src/push.js); this file only subscribes this browser.
|
|
5
|
+
//
|
|
6
|
+
// iPhone: Web Push works for a board added to the Home Screen (iOS 16.4+), so
|
|
7
|
+
// Safari in a tab gets one line saying how, and the button appears in the
|
|
8
|
+
// Home Screen app. Pure parts (pushSupport, pushUi) are shared with the tests.
|
|
9
|
+
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
11
|
+
const BELL = '<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="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'
|
|
12
|
+
|
|
13
|
+
export const isIos = (ua = '', touch = 0) => /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && touch > 1)
|
|
14
|
+
|
|
15
|
+
// What this browser can do -> { state } : insecure | add_to_home | unsupported | denied | off
|
|
16
|
+
export function pushSupport({ ua = '', touch = 0, standalone = false, hasPush = false, hasSW = false, secure = true, permission = 'default' } = {}) {
|
|
17
|
+
const ios = isIos(ua, touch)
|
|
18
|
+
if (!secure) return { state: 'insecure', ios }
|
|
19
|
+
if (ios && !standalone) return { state: 'add_to_home', ios }
|
|
20
|
+
if (!hasPush || !hasSW) return { state: 'unsupported', ios }
|
|
21
|
+
if (permission === 'denied') return { state: 'denied', ios }
|
|
22
|
+
return { state: 'off', ios }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The block for a slot. st: pushSupport() plus { on, busy, note, noteBad }
|
|
26
|
+
export function pushUi(st = {}) {
|
|
27
|
+
const note = st.note ? '<p class="push-note' + (st.noteBad ? ' bad' : '') + '" role="status">' + esc(st.note) + '</p>' : ''
|
|
28
|
+
const busy = st.busy ? ' disabled aria-busy="true"' : ''
|
|
29
|
+
const wrap = (inner) => '<div class="push-box">' + BELL + '<div class="push-main">' + inner + note + '</div></div>'
|
|
30
|
+
switch (st.on ? 'on' : st.state) {
|
|
31
|
+
case 'add_to_home': return wrap('<p class="push-line">Add Leftoff to your Home Screen first (Share → Add to Home Screen), then turn on notifications here.</p>')
|
|
32
|
+
case 'denied': return wrap('<p class="push-line">Notifications are blocked for Leftoff on this device. Allow them in ' + (st.ios ? 'Settings → Notifications → Leftoff' : 'the browser’s site settings') + ', then come back.</p>')
|
|
33
|
+
case 'on': return wrap('<p class="push-line"><strong>Notifications on for this device</strong></p><div class="push-acts"><button type="button" class="btn" data-push="test"' + busy + '>Send a test</button><button type="button" class="btn ghost" data-push="off"' + busy + '>Turn off</button></div>')
|
|
34
|
+
case 'off': return wrap('<p class="push-line">Get a notification when Claude needs you or a Live reply lands.</p><div class="push-acts"><button type="button" class="btn primary" data-push="on"' + busy + '>' + (st.busy ? 'Turning on…' : 'Turn on notifications') + '</button></div>')
|
|
35
|
+
default: return '' // no Push here (or not https): nothing to offer
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
40
|
+
const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)), (c) => c.charCodeAt(0))
|
|
41
|
+
|
|
42
|
+
const ui = { st: null, on: false, sub: null, busy: false, note: '', noteBad: false, api: null, refreshed: false }
|
|
43
|
+
|
|
44
|
+
function env() {
|
|
45
|
+
return {
|
|
46
|
+
ua: navigator.userAgent, touch: navigator.maxTouchPoints || 0,
|
|
47
|
+
standalone: !!(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true,
|
|
48
|
+
hasPush: 'PushManager' in window && 'Notification' in window, hasSW: 'serviceWorker' in navigator,
|
|
49
|
+
secure: !!window.isSecureContext, permission: 'Notification' in window ? Notification.permission : 'default',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function registration() {
|
|
54
|
+
if (!('serviceWorker' in navigator)) return null
|
|
55
|
+
return (await navigator.serviceWorker.getRegistration('/')) || (await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => null))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keyOf = (sub) => { try { const k = sub.options && sub.options.applicationServerKey; return k ? b64u(k) : null } catch { return null } }
|
|
59
|
+
|
|
60
|
+
async function load() {
|
|
61
|
+
const e = env()
|
|
62
|
+
ui.st = pushSupport(e)
|
|
63
|
+
ui.sub = null; ui.on = false
|
|
64
|
+
if (ui.st.state !== 'off') return
|
|
65
|
+
try { const reg = await registration(); ui.sub = reg && reg.pushManager ? await reg.pushManager.getSubscription() : null } catch { ui.sub = null }
|
|
66
|
+
ui.on = !!ui.sub && e.permission === 'granted'
|
|
67
|
+
// Once a page load: tell this Mac again (it shares with your other Macs), so a
|
|
68
|
+
// Mac that lost track, or a new one, knows this device.
|
|
69
|
+
if (ui.on && !ui.refreshed && ui.api) { ui.refreshed = true; ui.api('POST', '/v1/push/subscribe', { subscription: ui.sub.toJSON(), serverKey: keyOf(ui.sub), ua: navigator.userAgent }).catch(() => {}) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function paintPush() {
|
|
73
|
+
const slots = document.querySelectorAll('[data-push-slot]')
|
|
74
|
+
if (!slots.length) return
|
|
75
|
+
const html = ui.st ? pushUi({ ...ui.st, on: ui.on, busy: ui.busy, note: ui.note, noteBad: ui.noteBad }) : ''
|
|
76
|
+
for (const el of slots) { el.innerHTML = html; el.hidden = !html; el.setAttribute('data-pp', '1') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function turnOn() {
|
|
80
|
+
// The permission prompt first, straight from the tap (iOS asks only then).
|
|
81
|
+
const perm = await Notification.requestPermission()
|
|
82
|
+
if (perm !== 'granted') { ui.st = pushSupport(env()); ui.note = perm === 'denied' ? '' : 'Notifications weren’t allowed. Tap again to try.'; return }
|
|
83
|
+
await registration()
|
|
84
|
+
const reg = await navigator.serviceWorker.ready
|
|
85
|
+
const info = await ui.api('GET', '/v1/push')
|
|
86
|
+
let sub = await reg.pushManager.getSubscription()
|
|
87
|
+
// An existing subscription stays (the Mac that made it shares its key); a new one uses this Mac's key.
|
|
88
|
+
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: unb64u(info.publicKey) })
|
|
89
|
+
await ui.api('POST', '/v1/push/subscribe', { subscription: sub.toJSON(), serverKey: keyOf(sub) || info.publicKey, ua: navigator.userAgent })
|
|
90
|
+
ui.sub = sub; ui.on = true; ui.refreshed = true
|
|
91
|
+
ui.note = 'You’ll get a notification when Claude needs you or a Live reply lands.'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function turnOff() {
|
|
95
|
+
const sub = ui.sub
|
|
96
|
+
if (sub) {
|
|
97
|
+
const endpoint = sub.endpoint
|
|
98
|
+
try { await sub.unsubscribe() } catch {}
|
|
99
|
+
await ui.api('POST', '/v1/push/unsubscribe', { endpoint }).catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
ui.sub = null; ui.on = false
|
|
102
|
+
ui.note = 'Notifications off for this device.'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function sendTest() {
|
|
106
|
+
const r = await ui.api('POST', '/v1/push/test', { endpoint: ui.sub && ui.sub.endpoint })
|
|
107
|
+
if (r && r.ok) { ui.note = 'Sent from ' + (r.machine || 'your Mac') + '. It should show up in a few seconds.'; return }
|
|
108
|
+
if (r && r.error === 'not_subscribed') { ui.on = false; ui.sub = null; throw new Error('This Mac didn’t know this device. Turn notifications on again.') }
|
|
109
|
+
throw new Error('The push service didn’t take it' + (r && r.error ? ' (' + r.error + ')' : '') + '.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wire it once: api is app.js's api(method, path, body).
|
|
113
|
+
export function mountPush({ api }) {
|
|
114
|
+
ui.api = api
|
|
115
|
+
document.addEventListener('click', async (ev) => {
|
|
116
|
+
const b = ev.target.closest && ev.target.closest('[data-push]')
|
|
117
|
+
if (!b || ui.busy) return
|
|
118
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
119
|
+
const what = b.getAttribute('data-push')
|
|
120
|
+
ui.busy = true; ui.note = ''; ui.noteBad = false; paintPush()
|
|
121
|
+
try {
|
|
122
|
+
if (what === 'on') await turnOn()
|
|
123
|
+
else if (what === 'off') await turnOff()
|
|
124
|
+
else if (what === 'test') await sendTest()
|
|
125
|
+
} catch (e) { ui.note = e && e.message ? e.message : String(e); ui.noteBad = true }
|
|
126
|
+
ui.busy = false
|
|
127
|
+
paintPush()
|
|
128
|
+
})
|
|
129
|
+
load().then(paintPush, paintPush)
|
|
130
|
+
// Settings is drawn on demand: paint a slot the moment one appears.
|
|
131
|
+
const app = document.getElementById('app')
|
|
132
|
+
if (app && window.MutationObserver) new MutationObserver(() => { if (document.querySelector('[data-push-slot]:not([data-pp])')) paintPush() }).observe(app, { childList: true, subtree: true })
|
|
133
|
+
// A tapped notification: the service worker asks the open board to show that session.
|
|
134
|
+
if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', (ev) => {
|
|
135
|
+
const d = ev.data || {}
|
|
136
|
+
if (d.type !== 'leftoff-open' || typeof d.url !== 'string') return
|
|
137
|
+
try { const u = new URL(d.url, location.origin); if (u.origin === location.origin && location.hash !== u.hash) location.hash = u.hash } catch {}
|
|
138
|
+
})
|
|
139
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !ui.busy) load().then(paintPush, paintPush) })
|
|
140
|
+
}
|
package/ui/sw.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// Leftoff's service worker (v0.4.6).
|
|
2
|
-
// can't be reached (Tailscale off on the
|
|
3
|
-
// stopped), a page navigation shows
|
|
4
|
-
// It caches nothing else: no API
|
|
5
|
-
// request goes straight to the
|
|
1
|
+
// Leftoff's service worker (v0.4.6). Two jobs:
|
|
2
|
+
// 1. When the Mac running Leftoff can't be reached (Tailscale off on the
|
|
3
|
+
// phone, the Mac asleep, the daemon stopped), a page navigation shows
|
|
4
|
+
// offline.html instead of Safari's error. It caches nothing else: no API
|
|
5
|
+
// data, no board pages, no assets. Every other request goes straight to the
|
|
6
|
+
// network, untouched.
|
|
7
|
+
// 2. (0.9.8) Phone notifications: a push from the Mac that owns a session
|
|
8
|
+
// becomes a notification (the session's title, one short line, a tag per
|
|
9
|
+
// session and kind so a burst replaces the last one), and a tap opens that
|
|
10
|
+
// session (its Live chat, or the permission card at the top).
|
|
6
11
|
const CACHE = 'leftoff-offline-v1'
|
|
7
12
|
const OFFLINE = ['/offline.html', '/offline.css', '/icon-192.png']
|
|
8
13
|
|
|
@@ -45,3 +50,45 @@ function slow(url) {
|
|
|
45
50
|
function offline() {
|
|
46
51
|
return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
|
|
47
52
|
}
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ notifications (0.9.8)
|
|
55
|
+
|
|
56
|
+
// Safari must show every push (it takes the permission back after a few
|
|
57
|
+
// silent ones), so only other browsers skip one for the session already on screen.
|
|
58
|
+
const MAY_SKIP = /Chrome|CriOS|Firefox|FxiOS|Edg\//.test(self.navigator.userAgent || '')
|
|
59
|
+
const sameSession = (clientUrl, url) => {
|
|
60
|
+
try { const a = new URL(clientUrl).hash, b = new URL(url, self.location.origin).hash; return !!b && b !== '#/' && (a === b || a.startsWith(b + '/') || a.startsWith(b + '?')) } catch { return false }
|
|
61
|
+
}
|
|
62
|
+
const windows = () => self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
63
|
+
|
|
64
|
+
self.addEventListener('push', (event) => {
|
|
65
|
+
let d = {}
|
|
66
|
+
try { d = event.data ? event.data.json() : {} } catch { d = { body: event.data ? event.data.text() : '' } }
|
|
67
|
+
const url = typeof d.url === 'string' && d.url.startsWith('/') ? d.url : '/'
|
|
68
|
+
event.waitUntil((async () => {
|
|
69
|
+
if (MAY_SKIP && d.kind !== 'test') {
|
|
70
|
+
const on = (await windows()).some((c) => c.focused && c.visibilityState === 'visible' && sameSession(c.url, url))
|
|
71
|
+
if (on) return
|
|
72
|
+
}
|
|
73
|
+
await self.registration.showNotification(String(d.title || 'Leftoff').slice(0, 80), {
|
|
74
|
+
body: String(d.body || '').slice(0, 180), tag: d.tag || undefined, renotify: !!d.tag, icon: '/icon-192.png',
|
|
75
|
+
data: { url, kind: d.kind || null, sessionId: d.sessionId || null }, timestamp: Date.now(),
|
|
76
|
+
})
|
|
77
|
+
})())
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
self.addEventListener('notificationclick', (event) => {
|
|
81
|
+
event.notification.close()
|
|
82
|
+
const url = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href
|
|
83
|
+
event.waitUntil((async () => {
|
|
84
|
+
const list = (await windows()).filter((c) => new URL(c.url).origin === self.location.origin)
|
|
85
|
+
const c = list.find((x) => x.focused) || list[0]
|
|
86
|
+
if (c) {
|
|
87
|
+
// the board routes by hash: tell the page (no reload), then bring it forward
|
|
88
|
+
c.postMessage({ type: 'leftoff-open', url })
|
|
89
|
+
try { await c.focus() } catch {}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
await self.clients.openWindow(url)
|
|
93
|
+
})())
|
|
94
|
+
})
|