@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/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
+ }
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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
- '<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>' +
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')
@@ -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
- // -> { ok, how: 'local' | 'direct' | null, hint: text with `commands` or null }
15
- export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false } = {}) {
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
- event.respondWith(fetch(req).then((res) => (res.status >= 502 && res.status <= 504 && !res.headers.get('x-leftoff-page') ? offline() : res)).catch(offline))
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
  }