@leftoff/cli 0.9.8-beta.1 → 0.9.8-beta.2
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 +14 -0
- package/bin/leftoff.js +311 -304
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +202 -13
- package/ui/app.js +323 -19
- package/ui/index.html +14 -6
- package/ui/live-status.js +89 -0
- package/ui/live-view.js +171 -0
- package/ui/nav.js +88 -0
- package/ui/needs.js +32 -1
- package/ui/pull.js +31 -0
- package/ui/sw.js +14 -1
- package/ui/tips.js +117 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 0.9.8 Live link: what a message sent from the board is doing right now,
|
|
2
|
+
// shared by the daemon (src/live-link.js keeps it per message) and the board
|
|
3
|
+
// (which draws it with a spinner and the time since Send).
|
|
4
|
+
//
|
|
5
|
+
// Channels can't stream tokens: Claude answers in one go. What we do see is
|
|
6
|
+
// the channel's delivery (received), the mod's hooks for that session (a turn
|
|
7
|
+
// started, a turn ended), the session's transcript as it is written (each
|
|
8
|
+
// tool call and each block of text) and permission prompts. Those move one
|
|
9
|
+
// message through:
|
|
10
|
+
//
|
|
11
|
+
// sent ─► received ─► working (running Bash: ls · waiting for your OK) ─► replied
|
|
12
|
+
// └─► failed └─► stopped (the turn ended with no answer)
|
|
13
|
+
// received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
|
|
14
|
+
// something happens again; replied and failed are final.
|
|
15
|
+
|
|
16
|
+
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
17
|
+
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
18
|
+
const FINAL = new Set(['replied', 'failed'])
|
|
19
|
+
|
|
20
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
21
|
+
|
|
22
|
+
export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
|
|
23
|
+
|
|
24
|
+
// 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
|
|
26
|
+
export function stepStatus(st, ev = {}) {
|
|
27
|
+
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
28
|
+
const at = ev.at || s.last
|
|
29
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
|
|
30
|
+
switch (ev.type) {
|
|
31
|
+
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
32
|
+
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
|
33
|
+
case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
|
|
34
|
+
case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 80) }; s.waiting = null; break
|
|
35
|
+
case 'tool_done': {
|
|
36
|
+
const name = clip(ev.tool || (s.tool && s.tool.name), 40)
|
|
37
|
+
if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 80), asked: !!ev.asked })
|
|
38
|
+
if (s.ran.length > 20) s.ran = s.ran.slice(-20)
|
|
39
|
+
s.tool = null; s.waiting = null
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 80) }; break
|
|
43
|
+
case 'ask_done': s.waiting = null; break
|
|
44
|
+
case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
|
|
45
|
+
case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
|
|
46
|
+
default: return s
|
|
47
|
+
}
|
|
48
|
+
s.last = at
|
|
49
|
+
return s
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
53
|
+
export function statusView(st, now = Date.now()) {
|
|
54
|
+
if (!st) return null
|
|
55
|
+
const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
56
|
+
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
57
|
+
let label
|
|
58
|
+
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
59
|
+
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
60
|
+
else if (st.phase === 'working') {
|
|
61
|
+
if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
|
|
62
|
+
else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
|
|
63
|
+
else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
|
|
64
|
+
} else if (st.phase === 'replied') label = 'Replied'
|
|
65
|
+
else if (st.phase === 'stopped') label = 'Stopped without a reply'
|
|
66
|
+
else label = 'Didn’t arrive'
|
|
67
|
+
const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
|
|
68
|
+
const steps = ['Sent', 'Received', st.phase === 'stopped' ? 'Stopped' : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
|
|
69
|
+
.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' }))
|
|
70
|
+
const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
71
|
+
const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
|
|
72
|
+
return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4s, 1m 05s, 12m
|
|
76
|
+
export function elapsedText(ms) {
|
|
77
|
+
const s = Math.max(0, Math.round(Number(ms || 0) / 1000))
|
|
78
|
+
if (s < 60) return s + 's'
|
|
79
|
+
const m = Math.floor(s / 60)
|
|
80
|
+
return m < 10 ? m + 'm ' + String(s % 60).padStart(2, '0') + 's' : m + 'm'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The tools that ran in this turn without a permission prompt: a note under the reply.
|
|
84
|
+
export function ranWithoutAsking(st) {
|
|
85
|
+
const seen = new Set()
|
|
86
|
+
const out = []
|
|
87
|
+
for (const r of (st && st.ran) || []) { if (r.asked || seen.has(r.tool)) continue; seen.add(r.tool); out.push(r.tool) }
|
|
88
|
+
return out
|
|
89
|
+
}
|
package/ui/live-view.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 0.9.8 Live link on the board: the pieces of the Live box and the live card
|
|
2
|
+
// that are pure (and unit tested), plus the small DOM keeper that lets the Live
|
|
3
|
+
// thread survive the board's re-renders: the same list element stays on the
|
|
4
|
+
// page, messages are diffed in, and it sticks to the bottom only when you are
|
|
5
|
+
// already there.
|
|
6
|
+
import { statusView, elapsedText, ranWithoutAsking } from './live-status.js'
|
|
7
|
+
|
|
8
|
+
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
|
+
export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
|
|
10
|
+
|
|
11
|
+
// The name a session goes by. A live session is never "Untitled": until it has
|
|
12
|
+
// a title (its first prompt, Claude Code's /rename, a generated one) it goes by
|
|
13
|
+
// its repo and branch.
|
|
14
|
+
export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
15
|
+
if (!s) return untitled
|
|
16
|
+
if (s.title) return s.title
|
|
17
|
+
if (isLiveSession(s)) {
|
|
18
|
+
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
|
|
19
|
+
const branch = s.branch || (s.repo && s.repo.branch) || null
|
|
20
|
+
const where = [repo, branch && branch !== repo ? branch : null].filter(Boolean).join(' · ')
|
|
21
|
+
if (where) return where
|
|
22
|
+
return 'Live session'
|
|
23
|
+
}
|
|
24
|
+
return untitled
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which pills a card shows: a live session that is just waiting for you shows
|
|
28
|
+
// one Live pill (never "dormant"); one that is working or needs you shows that too.
|
|
29
|
+
export function cardPills(s) {
|
|
30
|
+
const live = isLiveSession(s)
|
|
31
|
+
const st = s && s.status
|
|
32
|
+
if (!live) return { status: st || null, live: false }
|
|
33
|
+
return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The "N empty sessions" fold never takes a live session.
|
|
37
|
+
export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
|
|
38
|
+
|
|
39
|
+
// Who sent a message: "You · from <device>". The path (relay, direct,
|
|
40
|
+
// Tailscale) is added only when it explains something: the message didn't arrive.
|
|
41
|
+
const PATHS = { relay: 'through the relay', direct: 'direct', tailscale: 'over Tailscale', peer: 'through another Mac', local: null }
|
|
42
|
+
export function senderLabel(m) {
|
|
43
|
+
if (!m) return ''
|
|
44
|
+
if (m.from === 'claude') return 'Claude'
|
|
45
|
+
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
46
|
+
const by = m.by || {}
|
|
47
|
+
let device = by.device || null
|
|
48
|
+
if (!device && by.transport === 'local') device = 'this Mac'
|
|
49
|
+
if (!device && m.where) { // older entries: just the where line
|
|
50
|
+
const w = String(m.where)
|
|
51
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
52
|
+
}
|
|
53
|
+
let label = 'You' + (device ? ' · from ' + device : '')
|
|
54
|
+
const path = PATHS[by.transport] || null
|
|
55
|
+
if (path && m.state === 'failed') label += ' (' + path + ')'
|
|
56
|
+
return label
|
|
57
|
+
}
|
|
58
|
+
export function senderTitle(m) {
|
|
59
|
+
const by = (m && m.by) || {}
|
|
60
|
+
const path = PATHS[by.transport] || null
|
|
61
|
+
return [by.device ? 'From ' + by.device : null, path ? 'Path: ' + path : by.transport === 'local' ? 'Path: this Mac' : null].filter(Boolean).join(' · ') || (m && m.where) || ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stick to the bottom? Only when you are already within `slack` px of it.
|
|
65
|
+
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
66
|
+
return scrollHeight - clientHeight - scrollTop <= slack
|
|
67
|
+
}
|
|
68
|
+
// After new content: follow it, or leave the view where it is and count what is new.
|
|
69
|
+
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
70
|
+
if (wasAtBottom) return { follow: true, unseen: 0 }
|
|
71
|
+
return { follow: false, unseen: unseen + Math.max(0, newCount) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// #/s/<id>/live: the session page, scrolled to the Live box with its input ready.
|
|
75
|
+
export function liveHref(id) { return '#/s/' + encodeURIComponent(String(id)) + '/live' }
|
|
76
|
+
export function parseLiveRoute(hash) {
|
|
77
|
+
const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
|
|
78
|
+
const p = h.split('/').filter(Boolean)
|
|
79
|
+
return p[0] === 's' && p[1] && p[2] === 'live' ? decodeURIComponent(p[1]) : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An unread dot on the Chat button: Claude replied after you last looked.
|
|
83
|
+
export function hasUnread(lastReplyAt, seenAt) {
|
|
84
|
+
const r = Date.parse(lastReplyAt || '')
|
|
85
|
+
if (isNaN(r)) return false
|
|
86
|
+
const s = Date.parse(seenAt || '')
|
|
87
|
+
return isNaN(s) || r > s
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------ the thread's HTML
|
|
91
|
+
|
|
92
|
+
// opts: { esc, ago, md (markdown -> html), now }
|
|
93
|
+
export function entryKey(m) { return String(m.id || '') + ':' + m.from }
|
|
94
|
+
export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
|
|
95
|
+
const who = senderLabel(m)
|
|
96
|
+
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
97
|
+
let body
|
|
98
|
+
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
99
|
+
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
100
|
+
let foot = ''
|
|
101
|
+
if (m.from === 'claude') {
|
|
102
|
+
const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
|
|
103
|
+
if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
|
|
104
|
+
if (ran.length) foot += '<div class="lv-note">Ran without asking: ' + esc(ran.join(', ')) + ' <span class="faint">(read-only or allowed in your Claude Code settings, so no Allow/Deny)</span></div>'
|
|
105
|
+
} else if (m.status) {
|
|
106
|
+
foot = statusHtml(m.status, { esc, now, error: m.errorText })
|
|
107
|
+
} else if (m.state) {
|
|
108
|
+
foot = '<div class="lv-status ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received by the session' : 'Sent, waiting for the session') + '</div>'
|
|
109
|
+
}
|
|
110
|
+
return '<li class="lv lv-' + esc(m.from) + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(entrySig(m))) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span></div>' + body + foot + '</li>'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function statusHtml(st, { esc, now = Date.now(), error = null }) {
|
|
114
|
+
const v = statusView(st, now)
|
|
115
|
+
if (!v) return ''
|
|
116
|
+
const label = st.phase === 'failed' && error ? error : v.label
|
|
117
|
+
const steps = v.steps.map((x) => '<span class="lvs-step ' + x.state + '">' + esc(x.name) + '</span>').join('<span class="lvs-arrow" aria-hidden="true">→</span>')
|
|
118
|
+
return '<div class="lv-status ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '"' + (v.final ? ' data-final="' + esc(String(st.endedAt || '')) + '"' : '') + ' role="status">' +
|
|
119
|
+
(st.phase === 'replied' // done: the steps say it all, with the time it took
|
|
120
|
+
? '<div class="lvs-steps">' + steps + '<span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>'
|
|
121
|
+
: '<div class="lvs-steps">' + steps + '</div>' +
|
|
122
|
+
'<div class="lvs-line">' + (v.spin ? '<span class="lv-spin" aria-hidden="true"></span>' : '') + '<span class="lvs-label">' + esc(label) + '</span><span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { ranWithoutAsking }
|
|
126
|
+
|
|
127
|
+
// A content signature, to replace only the entries that changed (the time-ago
|
|
128
|
+
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
129
|
+
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
130
|
+
export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
131
|
+
|
|
132
|
+
// ------------------------------------------------------------ the DOM keeper
|
|
133
|
+
|
|
134
|
+
// Keeps the thread's <ol> across re-renders of the session page.
|
|
135
|
+
export function createLiveKeeper() {
|
|
136
|
+
const kept = { sid: null, wrap: null, unseen: 0 }
|
|
137
|
+
return {
|
|
138
|
+
// Before the page is replaced: hold on to the current thread.
|
|
139
|
+
before(root) {
|
|
140
|
+
const w = root && root.querySelector('.live-thread')
|
|
141
|
+
if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
|
|
142
|
+
},
|
|
143
|
+
// After: put the old thread back in place of the new one and diff the new entries in.
|
|
144
|
+
after(root) {
|
|
145
|
+
const fresh = root && root.querySelector('.live-thread')
|
|
146
|
+
if (!fresh) { kept.wrap = null; return null }
|
|
147
|
+
const old = kept.wrap
|
|
148
|
+
if (!old || old.dataset.sid !== fresh.dataset.sid) { kept.unseen = 0; const ol = fresh.querySelector('.live-log'); if (ol) ol.scrollTop = ol.scrollHeight; return { thread: fresh, follow: true, first: true } }
|
|
149
|
+
const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
|
|
150
|
+
fresh.replaceWith(old)
|
|
151
|
+
if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
|
|
152
|
+
const wasAtBottom = atBottom(oldOl)
|
|
153
|
+
const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
|
|
154
|
+
let added = 0
|
|
155
|
+
let prev = null
|
|
156
|
+
for (const li of [...newOl.children]) {
|
|
157
|
+
const cur = have.get(li.dataset.key)
|
|
158
|
+
have.delete(li.dataset.key)
|
|
159
|
+
if (!cur) { added++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
|
|
160
|
+
if (cur.dataset.sig !== li.dataset.sig) { cur.replaceWith(li); prev = li } else prev = cur
|
|
161
|
+
}
|
|
162
|
+
for (const li of have.values()) li.remove()
|
|
163
|
+
const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
|
|
164
|
+
kept.unseen = d.unseen
|
|
165
|
+
if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
|
|
166
|
+
return { thread: old, follow: d.follow, unseen: d.unseen }
|
|
167
|
+
},
|
|
168
|
+
seen() { kept.unseen = 0 },
|
|
169
|
+
get unseen() { return kept.unseen },
|
|
170
|
+
}
|
|
171
|
+
}
|
package/ui/nav.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The sidebar rail, the phone's bottom bar and Quick look (0.9.8). Pure functions
|
|
2
|
+
// over the daemon's sessions (shared with the unit tests); app.js wires them.
|
|
3
|
+
//
|
|
4
|
+
// Quick look: hover (or long-press on a phone) the Needs you item for the count
|
|
5
|
+
// and the five longest waits, with Allow once / Always / Deny on permission prompts
|
|
6
|
+
// (Always only when the Needs you page offers "don't ask again"). Those
|
|
7
|
+
// buttons are the Needs you page's own (data-action="ask-answer"): same request,
|
|
8
|
+
// same checks, and nothing is ever answered without a click.
|
|
9
|
+
|
|
10
|
+
import { esc, needsItems, needsLine, waitLabel, alwaysText, alwaysButton } from './needs.js'
|
|
11
|
+
|
|
12
|
+
// The nav item a route lights up. A session page belongs to the board, a
|
|
13
|
+
// catch-up to the digest.
|
|
14
|
+
export function navKey(name) {
|
|
15
|
+
if (name === 's' || name === 'board' || !name) return 'board'
|
|
16
|
+
if (name === 'catchup') return 'digest'
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// On a phone the bar holds four sections and More; these live under More.
|
|
21
|
+
export const MORE_KEYS = ['timeline', 'digest', 'whats-new', 'settings']
|
|
22
|
+
export const moreActive = (key) => MORE_KEYS.includes(key)
|
|
23
|
+
|
|
24
|
+
// The red count on Needs you: nothing at zero, 99+ past 99.
|
|
25
|
+
export function badgeText(n) {
|
|
26
|
+
const v = Math.max(0, Math.floor(Number(n) || 0))
|
|
27
|
+
if (!v) return ''
|
|
28
|
+
return v > 99 ? '99+' : String(v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function qlTitle(total) {
|
|
32
|
+
if (!total) return 'Nothing needs you'
|
|
33
|
+
return total + (total === 1 ? ' thing needs' : ' things need') + ' you'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "6m" from the need's wait, or from when it was asked when there's no wait clock.
|
|
37
|
+
export function qlAge(n, now = Date.now()) {
|
|
38
|
+
if (!n) return ''
|
|
39
|
+
if (n.waitSec != null) return waitLabel(n.waitSec).replace('waiting ', '')
|
|
40
|
+
const t = Date.parse(n.since || n.at || '')
|
|
41
|
+
if (!Number.isFinite(t)) return ''
|
|
42
|
+
return waitLabel(Math.max(1, (now - t) / 1000)).replace('waiting ', '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The popover's items: the same order as the Needs you page (blocked first,
|
|
46
|
+
// longest wait first), the first `limit` of them.
|
|
47
|
+
export function quickLookItems(sessions = [], limit = 5) {
|
|
48
|
+
const all = needsItems(sessions)
|
|
49
|
+
return { total: all.length, items: all.slice(0, limit), more: Math.max(0, all.length - limit) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A permission prompt with an id is the one kind answered inline.
|
|
53
|
+
export const qlAnswerable = (n) => !!(n && n.kind === 'permission' && n.ask && n.ask.id)
|
|
54
|
+
|
|
55
|
+
// opts: { mayAnswer, pending: { [askId]: label }, now }
|
|
56
|
+
export function qlRow(n, opts = {}) {
|
|
57
|
+
const s = n.session || {}
|
|
58
|
+
const askId = n.ask && n.ask.id ? n.ask.id : ''
|
|
59
|
+
const key = askId || 'reply:' + s.id
|
|
60
|
+
const mac = n.peer || s.machine || ''
|
|
61
|
+
const meta = [s.repoLabel, mac, qlAge(n, opts.now)].filter(Boolean).join(' · ')
|
|
62
|
+
const line = needsLine(n)
|
|
63
|
+
const always = qlAnswerable(n) ? alwaysText(n.ask) : ''
|
|
64
|
+
let h = '<li class="ql-item k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + (always ? ' has-always' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">' +
|
|
65
|
+
'<a class="ql-main" href="#/s/' + encodeURIComponent(s.id || '') + '">' +
|
|
66
|
+
'<span class="ql-t">' + esc(s.title || 'Untitled session') + '</span>' +
|
|
67
|
+
(line ? '<span class="ql-what" title="' + esc(line) + '">' + esc(line) + '</span>' : '') +
|
|
68
|
+
(meta ? '<span class="ql-meta mono">' + esc(meta) + '</span>' : '') + '</a>'
|
|
69
|
+
if (qlAnswerable(n)) {
|
|
70
|
+
const busy = opts.pending && opts.pending[key]
|
|
71
|
+
const dis = opts.mayAnswer === false || busy ? ' disabled' : ''
|
|
72
|
+
const off = opts.mayAnswer === false ? ' title="Answering from this device is off"' : ''
|
|
73
|
+
h += '<div class="ql-actions">' +
|
|
74
|
+
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + off + ' aria-label="Allow once: ' + esc(line) + '">' + (busy ? esc(busy) : 'Allow once') + '</button>' +
|
|
75
|
+
(always ? alwaysButton(n.ask, dis, { label: 'Always', scope: 'ql', session: s, title: off }) : '') +
|
|
76
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + off + ' aria-label="Deny: ' + esc(line) + '">Deny</button></div>'
|
|
77
|
+
}
|
|
78
|
+
return h + '</li>'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function quickLookHtml(model = {}, opts = {}) {
|
|
82
|
+
const total = model.total || 0
|
|
83
|
+
const items = model.items || []
|
|
84
|
+
let h = '<div class="ql-head"><h2 id="qlTitle">' + esc(qlTitle(total)) + '</h2></div>'
|
|
85
|
+
if (!items.length) h += '<p class="ql-empty">When a session asks for permission or a question, it shows up here.</p>'
|
|
86
|
+
else h += '<ul class="ql-list">' + items.map((n) => qlRow(n, opts)).join('') + '</ul>' + (model.more ? '<p class="ql-more">+' + model.more + ' more</p>' : '')
|
|
87
|
+
return h + '<a class="ql-foot" href="#/waiting">Open Needs you <span aria-hidden="true">→</span></a>'
|
|
88
|
+
}
|
package/ui/needs.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// the panel on a session's page. Pure functions over the daemon's needsYou items
|
|
3
3
|
// (shared with the unit tests); app.js wires the clicks.
|
|
4
4
|
//
|
|
5
|
-
// Kinds: permission (Allow once / Allow for this session / Deny with a note),
|
|
5
|
+
// Kinds: permission (Allow once / Always allow here ⓘ / Allow for this session / Deny with a note),
|
|
6
6
|
// question (AskUserQuestion's options as buttons, plus Other…), terminal (only
|
|
7
7
|
// the terminal can answer: Go), reply (the turn ended on a question: a reply box
|
|
8
8
|
// when the mod can type it in). Nothing is ever answered without a click.
|
|
9
9
|
|
|
10
10
|
import { mdInline } from './changelog-md.js'
|
|
11
|
+
import { tipHtml, tipId } from './tips.js'
|
|
11
12
|
|
|
12
13
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
13
14
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
@@ -56,6 +57,35 @@ export function cardBadge(n) {
|
|
|
56
57
|
export const KBD_SUBMIT = 'Meta+Enter Control+Enter'
|
|
57
58
|
const kbdHint = (opts) => '<span class="kbd-hint" aria-hidden="true">' + esc(opts.kbd || '⌘↵') + '</span>'
|
|
58
59
|
|
|
60
|
+
// 0.9.8 "Always allow here" (Claude Code's "don't ask again"): offered only
|
|
61
|
+
// when Leftoff can name the exact rule(s). One line on the button; the rule(s),
|
|
62
|
+
// the file and the project are in its ⓘ tooltip and in its aria-label.
|
|
63
|
+
export function alwaysText(ask) {
|
|
64
|
+
const a = ask && ask.always
|
|
65
|
+
if (!a || !Array.isArray(a.rules) || !a.rules.length) return ''
|
|
66
|
+
return 'adds ' + a.rules.join(', ') + ' to ' + (a.file || '.claude/settings.local.json')
|
|
67
|
+
}
|
|
68
|
+
// The tooltip's words: "Adds `Bash(x)` to .claude/settings.local.json in api, so
|
|
69
|
+
// Claude won't ask for this again here." (markup; the rules in mono)
|
|
70
|
+
export function alwaysTipHtml(ask, session = null) {
|
|
71
|
+
const a = ask && ask.always
|
|
72
|
+
if (!alwaysText(ask)) return ''
|
|
73
|
+
const where = a.project || (session && session.repoLabel) || ''
|
|
74
|
+
return 'Adds ' + a.rules.map((r) => '<code>' + esc(r) + '</code>').join(', ') + ' to <code>' + esc(a.file || '.claude/settings.local.json') + '</code>' +
|
|
75
|
+
(where ? ' in <b>' + esc(where) + '</b>' : ' in this project') + ', so Claude won’t ask for ' + (a.rules.length > 1 ? 'these' : 'this') + ' again here.'
|
|
76
|
+
}
|
|
77
|
+
// opts: { label ('Always allow here'; Quick look says 'Always'), scope (keeps the
|
|
78
|
+
// tooltip's id unique when Quick look and the page show the same ask), session, title }
|
|
79
|
+
export function alwaysButton(ask, dis = '', opts = {}) {
|
|
80
|
+
const t = alwaysText(ask)
|
|
81
|
+
if (!t) return ''
|
|
82
|
+
const label = opts.label || 'Always allow here'
|
|
83
|
+
const id = tipId(opts.scope || 'always', ask.id)
|
|
84
|
+
return '<span class="always-wrap">' +
|
|
85
|
+
'<button type="button" class="btn always" data-action="ask-answer" data-choice="allow_always"' + dis + (opts.title || '') + ' aria-label="' + esc(label + ': ' + t) + '" aria-describedby="' + esc(id) + '">' + esc(label) + '</button>' +
|
|
86
|
+
tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
// opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
|
|
60
90
|
export function needPanel(n, opts = {}) {
|
|
61
91
|
const s = n.session || {}
|
|
@@ -81,6 +111,7 @@ export function needPanel(n, opts = {}) {
|
|
|
81
111
|
if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
|
|
82
112
|
h += '<div class="need-actions">' +
|
|
83
113
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
114
|
+
alwaysButton(n.ask, dis, { session: s }) +
|
|
84
115
|
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
85
116
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
86
117
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
package/ui/pull.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pull to refresh on phones (0.9.8). Pull down from the top of any page and let
|
|
2
|
+
// go past the line: the page reads its data again (no full reload). Pure
|
|
3
|
+
// functions here (shared with the unit tests); app.js wires the touches.
|
|
4
|
+
|
|
5
|
+
export const PULL_TRIGGER = 70 // px of finger travel that arms a refresh
|
|
6
|
+
export const PULL_MAX = 110 // how far the spinner travels at most
|
|
7
|
+
|
|
8
|
+
// Where a pull never starts: text fields, the Live box, menus and sheets,
|
|
9
|
+
// sideways scrollers, and anything open on top of the page.
|
|
10
|
+
export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
|
|
11
|
+
|
|
12
|
+
// The phone layout on a touch screen, at the very top of the page, from a
|
|
13
|
+
// place that isn't a field or an overlay, and with no overlay open.
|
|
14
|
+
export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
|
|
15
|
+
if (!coarse || !narrow || overlayOpen) return false
|
|
16
|
+
if (scrollY > 0) return false
|
|
17
|
+
if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One finger movement: how far the spinner sits (with some resistance), how
|
|
22
|
+
// far round it is, and whether letting go now refreshes. Sideways first means
|
|
23
|
+
// it isn't a pull.
|
|
24
|
+
export function pullStep(dy, dx = 0, { trigger = PULL_TRIGGER, max = PULL_MAX } = {}) {
|
|
25
|
+
if (!(dy > 0) || Math.abs(dx) > dy) return { pulling: false, dist: 0, progress: 0, armed: false }
|
|
26
|
+
const dist = Math.min(max, dy < trigger ? dy * 0.8 : trigger * 0.8 + (dy - trigger) * 0.35)
|
|
27
|
+
return { pulling: true, dist: Math.round(dist), progress: Math.min(1, dy / trigger), armed: dy >= trigger }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What letting go does.
|
|
31
|
+
export const pullRelease = (step) => (step && step.pulling && step.armed ? 'refresh' : 'cancel')
|
package/ui/sw.js
CHANGED
|
@@ -22,13 +22,26 @@ self.addEventListener('fetch', (event) => {
|
|
|
22
22
|
if (req.mode === 'navigate') {
|
|
23
23
|
// A 502/503/504 is Tailscale Serve saying the daemon behind it is down.
|
|
24
24
|
// (The relay's own "Your Macs are asleep" page, x-leftoff-page, shows as is.)
|
|
25
|
-
|
|
25
|
+
// 0.9.7: never an endless blank page. The relay answers within a second
|
|
26
|
+
// (the board, or its own connecting page); anything slower than NAV_TIMEOUT_MS
|
|
27
|
+
// gets a page that says so, with Try again.
|
|
28
|
+
const net = fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline)
|
|
29
|
+
event.respondWith(Promise.race([net, new Promise((resolve) => setTimeout(() => resolve(slow(req.url)), NAV_TIMEOUT_MS))]))
|
|
26
30
|
return
|
|
27
31
|
}
|
|
28
32
|
// The offline page's own files, so it renders with no network.
|
|
29
33
|
if (OFFLINE.includes(url.pathname)) event.respondWith(fetch(req).catch(() => caches.match(url.pathname)))
|
|
30
34
|
})
|
|
31
35
|
|
|
36
|
+
const NAV_TIMEOUT_MS = 12000
|
|
37
|
+
function slow(url) {
|
|
38
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
|
|
39
|
+
const body = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Leftoff · still waiting</title>' +
|
|
40
|
+
'<style>body{margin:0;font:16px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:Canvas;color:CanvasText}main{max-width:460px;margin:0 auto;padding:max(48px,env(safe-area-inset-top)) 20px 40px}h1{font-size:26px;margin:0 0 10px}p{opacity:.75}a{display:flex;align-items:center;justify-content:center;min-height:48px;border-radius:12px;background:#d9622b;color:#fff;font-weight:600;text-decoration:none;margin-top:20px}</style></head>' +
|
|
41
|
+
'<body><main><h1>Leftoff is taking too long</h1><p>Your Mac hasn’t sent the board yet. It may be waking up.</p><a href="' + esc(url) + '">Try again</a></main></body></html>'
|
|
42
|
+
return new Response(body, { status: 504, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
function offline() {
|
|
33
46
|
return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
|
|
34
47
|
}
|
package/ui/tips.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// 0.9.8 info tips: a small ⓘ button beside a control, with the details in a
|
|
2
|
+
// tooltip. Hover or keyboard focus on the ⓘ shows it (desktop); a tap pins it
|
|
3
|
+
// open as a small popover (touch); a second tap, Escape or a click outside
|
|
4
|
+
// closes it. The tip is a role="tooltip" the control and the ⓘ both point at
|
|
5
|
+
// with aria-describedby, so screen readers get the words without opening it.
|
|
6
|
+
// tipHtml() is pure (the unit tests read it); wireTips() runs once in the board.
|
|
7
|
+
|
|
8
|
+
import { icon } from './icons.js'
|
|
9
|
+
|
|
10
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
11
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
12
|
+
|
|
13
|
+
// A DOM id from any string (an ask id can carry anything).
|
|
14
|
+
export const tipId = (scope, key) => 'tip-' + String(scope || 't').replace(/[^A-Za-z0-9_-]/g, '_') + '-' + String(key || 'x').replace(/[^A-Za-z0-9_-]/g, '_')
|
|
15
|
+
|
|
16
|
+
// The ⓘ button and its (hidden) tooltip. `html` is already escaped markup.
|
|
17
|
+
export function tipHtml({ id, label, html }) {
|
|
18
|
+
return '<button type="button" class="tip-btn" aria-label="' + esc(label) + '" aria-expanded="false" aria-controls="' + esc(id) + '" aria-describedby="' + esc(id) + '">' + icon('info') + '</button>' +
|
|
19
|
+
'<span class="tip" role="tooltip" id="' + esc(id) + '" hidden>' + html + '</span>'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const OPEN_DELAY = 120
|
|
23
|
+
const CLOSE_DELAY = 180
|
|
24
|
+
|
|
25
|
+
export function wireTips(doc = document) {
|
|
26
|
+
const win = doc.defaultView
|
|
27
|
+
let cur = null // { btn, tip, pinned }
|
|
28
|
+
let timer = 0
|
|
29
|
+
const later = (fn, ms) => { win.clearTimeout(timer); timer = win.setTimeout(fn, ms) }
|
|
30
|
+
const tipOf = (btn) => btn && doc.getElementById(btn.getAttribute('aria-controls'))
|
|
31
|
+
|
|
32
|
+
function place(btn, tip) {
|
|
33
|
+
const r = btn.getBoundingClientRect()
|
|
34
|
+
const vh = win.innerHeight
|
|
35
|
+
// kept on screen, and inside a small popover (Quick look) when it sits in one
|
|
36
|
+
const box = btn.closest('.quicklook')
|
|
37
|
+
const b = box ? box.getBoundingClientRect() : { left: 0, right: doc.documentElement.clientWidth }
|
|
38
|
+
const lo = b.left + 8
|
|
39
|
+
const hi = b.right - 8
|
|
40
|
+
tip.style.maxWidth = Math.min(320, hi - lo) + 'px'
|
|
41
|
+
const w = tip.offsetWidth
|
|
42
|
+
const h = tip.offsetHeight
|
|
43
|
+
let left = r.right - w // right-aligned with the ⓘ
|
|
44
|
+
left = Math.max(lo, Math.min(left, hi - w))
|
|
45
|
+
let top = r.bottom + 6
|
|
46
|
+
let up = false
|
|
47
|
+
if (top + h > vh - 8 && r.top - 6 - h >= 8) { top = r.top - 6 - h; up = true }
|
|
48
|
+
tip.style.left = Math.round(left) + 'px'
|
|
49
|
+
tip.style.top = Math.round(top) + 'px'
|
|
50
|
+
tip.classList.toggle('up', up)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function show(btn, pinned) {
|
|
54
|
+
const tip = tipOf(btn)
|
|
55
|
+
if (!tip) return
|
|
56
|
+
win.clearTimeout(timer)
|
|
57
|
+
if (cur && cur.btn !== btn) hide()
|
|
58
|
+
tip.hidden = false
|
|
59
|
+
btn.setAttribute('aria-expanded', 'true')
|
|
60
|
+
place(btn, tip)
|
|
61
|
+
cur = { btn, tip, pinned: !!pinned || !!(cur && cur.btn === btn && cur.pinned) }
|
|
62
|
+
}
|
|
63
|
+
function hide() {
|
|
64
|
+
win.clearTimeout(timer)
|
|
65
|
+
if (!cur) return
|
|
66
|
+
cur.tip.hidden = true
|
|
67
|
+
if (cur.btn.isConnected) cur.btn.setAttribute('aria-expanded', 'false')
|
|
68
|
+
cur = null
|
|
69
|
+
}
|
|
70
|
+
const inside = (t) => !!(cur && t && t.closest && (cur.btn.contains(t) || cur.tip.contains(t)))
|
|
71
|
+
|
|
72
|
+
doc.addEventListener('click', (e) => {
|
|
73
|
+
if (cur && !cur.tip.isConnected) cur = null // the list was redrawn under it
|
|
74
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
75
|
+
if (btn) {
|
|
76
|
+
e.preventDefault(); e.stopPropagation()
|
|
77
|
+
if (cur && cur.btn === btn && cur.pinned) return hide()
|
|
78
|
+
return show(btn, true)
|
|
79
|
+
}
|
|
80
|
+
if (cur && !inside(e.target)) hide()
|
|
81
|
+
}, true)
|
|
82
|
+
// Mouse: hover the ⓘ (or the tip itself, so it can be read and selected)
|
|
83
|
+
doc.addEventListener('pointerover', (e) => {
|
|
84
|
+
if (e.pointerType !== 'mouse') return
|
|
85
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
86
|
+
if (btn) return (cur && cur.btn === btn) ? win.clearTimeout(timer) : later(() => show(btn, false), OPEN_DELAY)
|
|
87
|
+
if (inside(e.target)) win.clearTimeout(timer)
|
|
88
|
+
})
|
|
89
|
+
doc.addEventListener('pointerout', (e) => {
|
|
90
|
+
if (e.pointerType !== 'mouse') return
|
|
91
|
+
const from = e.target.closest && e.target.closest('.tip-btn, .tip')
|
|
92
|
+
if (!from) return
|
|
93
|
+
if (inside(e.relatedTarget)) return
|
|
94
|
+
if (!cur) return win.clearTimeout(timer)
|
|
95
|
+
if (!cur.pinned) later(hide, CLOSE_DELAY)
|
|
96
|
+
})
|
|
97
|
+
// Keyboard: focus on the ⓘ shows it, leaving it hides it (unless tapped open)
|
|
98
|
+
doc.addEventListener('focusin', (e) => {
|
|
99
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
100
|
+
if (btn && btn.matches(':focus-visible')) show(btn, false)
|
|
101
|
+
})
|
|
102
|
+
doc.addEventListener('focusout', (e) => {
|
|
103
|
+
if (cur && !cur.pinned && e.target === cur.btn && !inside(e.relatedTarget)) hide()
|
|
104
|
+
})
|
|
105
|
+
// Escape closes it first (before Quick look or a dialog hears the key)
|
|
106
|
+
doc.addEventListener('keydown', (e) => {
|
|
107
|
+
if (e.key !== 'Escape' || !cur) return
|
|
108
|
+
e.preventDefault(); e.stopImmediatePropagation()
|
|
109
|
+
const b = cur.btn
|
|
110
|
+
const back = doc.activeElement && doc.activeElement !== b && cur.tip.contains(doc.activeElement)
|
|
111
|
+
hide()
|
|
112
|
+
if (back) b.focus()
|
|
113
|
+
}, true)
|
|
114
|
+
win.addEventListener('resize', () => { if (cur) place(cur.btn, cur.tip) })
|
|
115
|
+
win.addEventListener('scroll', () => { if (cur) { if (!cur.btn.isConnected) hide(); else place(cur.btn, cur.tip) } }, true)
|
|
116
|
+
return { hide, isOpen: () => !!cur }
|
|
117
|
+
}
|