@leftoff/cli 0.9.7 → 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 +21 -0
- package/bin/leftoff.js +351 -326
- package/docs/SECURITY.md +10 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +221 -0
- package/ui/app.js +442 -20
- 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 +33 -2
- package/ui/pull.js +31 -0
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +14 -1
- package/ui/tips.js +117 -0
package/ui/index.html
CHANGED
|
@@ -21,25 +21,33 @@
|
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<header class="top">
|
|
24
|
-
<a class="brand" href="#/" id="brand" title="Leftoff">
|
|
24
|
+
<a class="brand" href="#/" id="brand" title="Leftoff" aria-label="Leftoff">
|
|
25
25
|
<img src="favicon.svg" class="logo" alt="" width="22" height="22">
|
|
26
26
|
<span>Leftoff</span>
|
|
27
27
|
</a>
|
|
28
28
|
<nav class="tabs" aria-label="Sections">
|
|
29
29
|
<a href="#/" data-route="board"><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"><rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/></svg><span class="lbl">Board</span></a>
|
|
30
|
-
<a href="#/waiting" data-route="waiting" id="needsTab"><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><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
30
|
+
<a href="#/waiting" data-route="waiting" id="needsTab" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><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><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
31
|
<a href="#/lists" data-route="lists" id="listsTab"><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="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
32
|
<a href="#/prs" data-route="prs" id="prsTab"><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"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
33
|
-
<a href="#/timeline" data-route="timeline"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
34
|
-
<a href="#/digest" data-route="digest"><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 class="lbl">Digest</span></a>
|
|
33
|
+
<a href="#/timeline" data-route="timeline" class="tab-extra"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
34
|
+
<a href="#/digest" data-route="digest" class="tab-extra"><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 class="lbl">Digest</span></a>
|
|
35
|
+
<button type="button" class="tab-more" id="moreBtn" aria-haspopup="menu" aria-expanded="false" aria-controls="moreMenu"><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"><circle cx="12" cy="12" r="1"/> <circle cx="19" cy="12" r="1"/> <circle cx="5" cy="12" r="1"/></svg><span class="lbl">More</span><span class="moredot" hidden></span></button>
|
|
35
36
|
</nav>
|
|
36
37
|
<div class="topright">
|
|
37
38
|
<button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><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"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
38
39
|
<button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
39
|
-
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><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 class="newdot" hidden></span></a>
|
|
40
|
-
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><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 class="topdot" hidden></span></a>
|
|
40
|
+
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><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 class="lbl">What’s new</span><span class="newdot" hidden></span></a>
|
|
41
|
+
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><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 class="lbl">Settings</span><span class="topdot" hidden></span></a>
|
|
41
42
|
</div>
|
|
42
43
|
</header>
|
|
44
|
+
<div class="moremenu" id="moreMenu" role="menu" aria-label="More" hidden>
|
|
45
|
+
<a href="#/timeline" data-route="timeline" 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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span>Timeline</span></a>
|
|
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
|
+
<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
|
+
<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>
|
|
50
|
+
<div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
|
|
43
51
|
<div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
|
|
44
52
|
<div class="cmdk" id="cmdk" hidden>
|
|
45
53
|
<div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search">
|
|
@@ -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,7 +111,8 @@ 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>' +
|
|
84
|
-
|
|
114
|
+
alwaysButton(n.ask, dis, { session: s }) +
|
|
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 + '">' +
|
|
87
118
|
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
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/restore-gate.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
// route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
|
|
12
12
|
// machine the Mac the restore card belongs to
|
|
13
13
|
// phone a phone or tablet (never runs Leftoff)
|
|
14
|
-
//
|
|
15
|
-
|
|
14
|
+
// canAsk 0.9.8: that Mac takes Restore requests from other devices (GET /v1/restore has asks)
|
|
15
|
+
// -> { ok, how: 'local' | 'direct' | 'ask' | null, hint: text with `commands` or null }
|
|
16
|
+
// 'ask': the request goes to that Mac, where a person confirms before any tab opens.
|
|
17
|
+
export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false, canAsk = false } = {}) {
|
|
16
18
|
if (local && !hosted) return { ok: true, how: 'local', hint: null }
|
|
17
19
|
if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
|
|
18
20
|
const m = machine || 'the Mac'
|
|
21
|
+
if (canAsk && machine) return { ok: true, how: 'ask', hint: 'Restore asks ' + m + ' first: someone at ' + m + ' confirms before the tabs open.' }
|
|
19
22
|
if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
|
|
20
23
|
if (hosted) {
|
|
21
24
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
|
|
@@ -23,6 +26,27 @@ export function restoreAccess({ local = false, hosted = false, route = null, rou
|
|
|
23
26
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// 0.9.8: what the card says about a Restore it asked another Mac for.
|
|
30
|
+
// ask: { state, machine, count, expiresAt, result, error } -> { text, tone: 'info' | 'ok' | 'warn' }
|
|
31
|
+
export function askLine(ask, { clock = (iso) => iso } = {}) {
|
|
32
|
+
if (!ask) return null
|
|
33
|
+
const m = ask.machine || 'the Mac', n = ask.count || 0
|
|
34
|
+
const tabs = n + ' tab' + (n === 1 ? '' : 's')
|
|
35
|
+
switch (ask.state) {
|
|
36
|
+
case 'pending': return { text: 'Waiting for someone at ' + m + ' to confirm' + (ask.expiresAt ? ' (until ' + clock(ask.expiresAt) + ')' : '') + '…', tone: 'info' }
|
|
37
|
+
case 'confirming': return { text: 'Confirmed on ' + m + ': opening ' + tabs + '…', tone: 'info' }
|
|
38
|
+
case 'confirmed': {
|
|
39
|
+
const r = ask.result || {}
|
|
40
|
+
if (r.how === 'print') return { text: 'Confirmed on ' + m + ', but Ghostty didn’t open there: run `leftoff restore` on ' + m + '.', tone: 'warn' }
|
|
41
|
+
return { text: 'Confirmed on ' + m + ': ' + (r.opened || 0) + ' of ' + tabs + ' opened' + (r.failed ? ', ' + r.failed + ' didn’t' : '') + '.', tone: r.failed ? 'warn' : 'ok' }
|
|
42
|
+
}
|
|
43
|
+
case 'declined': return { text: 'Declined on ' + m + '. Nothing was opened.', tone: 'warn' }
|
|
44
|
+
case 'expired': return { text: 'Nobody confirmed on ' + m + ' in time. Nothing was opened.', tone: 'warn' }
|
|
45
|
+
case 'failed': return { text: 'It didn’t work on ' + m + ': ' + (ask.error || 'an error') + '.', tone: 'warn' }
|
|
46
|
+
default: return { text: ask.error || 'Couldn’t ask ' + m + '.', tone: 'warn' }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
// The hint as HTML: escaped, `commands` in <code>.
|
|
27
51
|
export function hintHtml(hint, esc) {
|
|
28
52
|
return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
|
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
|
}
|