@leftoff/cli 0.9.7 → 0.9.8-beta.10
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 +39 -0
- package/bin/leftoff.js +414 -379
- package/docs/SECURITY.md +10 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +425 -1
- package/ui/app.js +952 -40
- package/ui/index.html +18 -6
- package/ui/live-status.js +97 -0
- package/ui/live-view.js +374 -0
- package/ui/live-wake.js +96 -0
- package/ui/nav.js +88 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +33 -2
- package/ui/pull.js +31 -0
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +66 -6
- package/ui/tabs.js +216 -0
- package/ui/tips.js +117 -0
package/ui/index.html
CHANGED
|
@@ -21,25 +21,34 @@
|
|
|
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 class="push-slot" data-push-slot role="group" aria-label="Notifications" hidden></div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
|
|
43
52
|
<div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
|
|
44
53
|
<div class="cmdk" id="cmdk" hidden>
|
|
45
54
|
<div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search">
|
|
@@ -48,6 +57,9 @@
|
|
|
48
57
|
<div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
|
|
49
58
|
</div>
|
|
50
59
|
</div>
|
|
60
|
+
<nav class="tabstrip" id="tabStrip" aria-label="Open sessions" hidden></nav>
|
|
61
|
+
<div class="openrow" id="openRow" role="navigation" aria-label="Open sessions" hidden></div>
|
|
62
|
+
<div class="tabpager" id="tabPager" role="navigation" aria-label="Open sessions" hidden></div>
|
|
51
63
|
<main id="app"></main>
|
|
52
64
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
53
65
|
<script src="relay-client.js" type="module"></script>
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
// A message that arrived while Claude was already working on another one is
|
|
16
|
+
// folded into that turn (folded: the id of the message that owns the turn):
|
|
17
|
+
// it has no Working bubble of its own, and when the turn ends it is
|
|
18
|
+
// "Answered with the reply above".
|
|
19
|
+
|
|
20
|
+
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
21
|
+
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
22
|
+
const FINAL = new Set(['replied', 'failed'])
|
|
23
|
+
|
|
24
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
25
|
+
|
|
26
|
+
export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
|
|
27
|
+
|
|
28
|
+
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
29
|
+
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
|
|
30
|
+
export function stepStatus(st, ev = {}) {
|
|
31
|
+
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
32
|
+
const at = ev.at || s.last
|
|
33
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
|
|
34
|
+
switch (ev.type) {
|
|
35
|
+
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
36
|
+
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
|
37
|
+
case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
|
|
38
|
+
case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; s.waiting = null; break
|
|
39
|
+
case 'tool_done': {
|
|
40
|
+
const name = clip(ev.tool || (s.tool && s.tool.name), 40)
|
|
41
|
+
if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 300), asked: !!ev.asked })
|
|
42
|
+
if (s.ran.length > 20) s.ran = s.ran.slice(-20)
|
|
43
|
+
s.tool = null; s.waiting = null
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; break
|
|
47
|
+
case 'ask_done': s.waiting = null; break
|
|
48
|
+
case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
|
|
49
|
+
case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
|
|
50
|
+
case 'folded': if (s.phase === 'sent' || s.phase === 'received') s.phase = 'working'; s.folded = ev.owner || true; s.tool = null; s.waiting = null; break
|
|
51
|
+
case 'answered': s.phase = 'replied'; s.folded = ev.owner || s.folded || true; s.tool = null; s.waiting = null; s.endedAt = at; break // folded: the reply above is its answer
|
|
52
|
+
case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
|
|
53
|
+
default: return s
|
|
54
|
+
}
|
|
55
|
+
s.last = at
|
|
56
|
+
return s
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
60
|
+
export function statusView(st, now = Date.now()) {
|
|
61
|
+
if (!st) return null
|
|
62
|
+
const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
63
|
+
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
64
|
+
let label
|
|
65
|
+
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
66
|
+
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
67
|
+
else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
|
|
68
|
+
else if (st.phase === 'working') {
|
|
69
|
+
if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
|
|
70
|
+
else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
|
|
71
|
+
else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
|
|
72
|
+
} else if (st.phase === 'replied') label = st.folded ? 'Answered with the reply above' : 'Replied'
|
|
73
|
+
else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
|
|
74
|
+
else label = 'Didn’t arrive'
|
|
75
|
+
const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
|
|
76
|
+
const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
|
|
77
|
+
.map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
|
|
78
|
+
const spin = !quiet && !st.folded && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
79
|
+
const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
|
|
80
|
+
return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4s, 1m 05s, 12m
|
|
84
|
+
export function elapsedText(ms) {
|
|
85
|
+
const s = Math.max(0, Math.round(Number(ms || 0) / 1000))
|
|
86
|
+
if (s < 60) return s + 's'
|
|
87
|
+
const m = Math.floor(s / 60)
|
|
88
|
+
return m < 10 ? m + 'm ' + String(s % 60).padStart(2, '0') + 's' : m + 'm'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The tools that ran in this turn without a permission prompt: a note under the reply.
|
|
92
|
+
export function ranWithoutAsking(st) {
|
|
93
|
+
const seen = new Set()
|
|
94
|
+
const out = []
|
|
95
|
+
for (const r of (st && st.ran) || []) { if (r.asked || seen.has(r.tool)) continue; seen.add(r.tool); out.push(r.tool) }
|
|
96
|
+
return out
|
|
97
|
+
}
|
package/ui/live-view.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
const wrapped = isWrapped(s.title)
|
|
17
|
+
if (s.title && !wrapped) return s.title
|
|
18
|
+
// No name yet (or a raw <channel …> one an earlier beta kept): the first
|
|
19
|
+
// prompt's words, which another Mac's older Leftoff may have sent without a title.
|
|
20
|
+
const first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
|
|
21
|
+
if (first && (isLiveSession(s) || wrapped)) return first
|
|
22
|
+
if (isLiveSession(s)) {
|
|
23
|
+
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
|
|
24
|
+
const branch = s.branch || (s.repo && s.repo.branch) || null
|
|
25
|
+
const where = [repo, branch && branch !== repo ? branch : null].filter(Boolean).join(' · ')
|
|
26
|
+
if (where) return where
|
|
27
|
+
return 'Live session'
|
|
28
|
+
}
|
|
29
|
+
return untitled
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Is the card's name just where it runs ("repo · branch")? Then the repo chip
|
|
33
|
+
// under it would say the same thing twice.
|
|
34
|
+
export function titleIsWhere(s) {
|
|
35
|
+
if (!s || !isLiveSession(s)) return false
|
|
36
|
+
const t = sessionTitle(s)
|
|
37
|
+
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || ''
|
|
38
|
+
const branch = s.branch || (s.repo && s.repo.branch) || ''
|
|
39
|
+
return !!repo && (t === repo || t === repo + ' · ' + branch)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
|
|
43
|
+
const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
|
|
44
|
+
// The first line of a prompt in its own words: no channel wrapper (even one
|
|
45
|
+
// cut short), no system reminders; 60 characters at most.
|
|
46
|
+
export function promptLine(text) {
|
|
47
|
+
let t = String(text || '').replace(/^\s*<channel\b[^>]*>?/, '').replace(/<\/channel>\s*$/, '').replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
|
|
48
|
+
t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
|
|
49
|
+
t = t.replace(/\s+/g, ' ')
|
|
50
|
+
return t.length > 60 ? t.slice(0, 59) + '…' : t
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A title as HTML that wraps where it reads: after a "/" in a repo or branch
|
|
54
|
+
// (and at the spaces around " · "), not in the middle of a word.
|
|
55
|
+
export function titleHtml(text, esc) {
|
|
56
|
+
return esc(String(text == null ? '' : text)).split('/').join('/<wbr>')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Which pills a card shows: a live session that is just waiting for you shows
|
|
60
|
+
// one Live pill (never "dormant"); one that is working or needs you shows that too.
|
|
61
|
+
export function cardPills(s) {
|
|
62
|
+
const live = isLiveSession(s)
|
|
63
|
+
const st = s && s.status
|
|
64
|
+
if (!live) return { status: st || null, live: false }
|
|
65
|
+
return { status: ['active', 'needs_input', 'background'].includes(st) ? st : null, live: true }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Is Claude running a turn in this live session right now? The Live pill's dot
|
|
69
|
+
// pulses while it is. The same signals the board already shows: the session's
|
|
70
|
+
// own status (a turn typed in the terminal, which the hooks see start), or the
|
|
71
|
+
// board message whose turn is still going, read the way its Working bubble
|
|
72
|
+
// reads it (spinning: sent, received or working; not waiting for your OK, not
|
|
73
|
+
// gone quiet). Idle, waiting on you, or another Mac's daemon that doesn't say:
|
|
74
|
+
// a steady dot.
|
|
75
|
+
export function liveWorking(s, now = Date.now()) {
|
|
76
|
+
if (!cardPills(s).live) return false
|
|
77
|
+
if (s.status === 'active') return true
|
|
78
|
+
const t = s.liveLink && s.liveLink.turn
|
|
79
|
+
if (!t || !t.phase) return false
|
|
80
|
+
const v = statusView(t, now)
|
|
81
|
+
return !!(v && v.spin)
|
|
82
|
+
}
|
|
83
|
+
const LIVE_TIP = 'Live link: send this session messages from the board or your phone'
|
|
84
|
+
export function liveBadgeHtml(s, now = Date.now()) {
|
|
85
|
+
if (!cardPills(s).live) return ''
|
|
86
|
+
const w = liveWorking(s, now)
|
|
87
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
91
|
+
// CSS pulse each time: a visible hitch every second or so while a session
|
|
92
|
+
// works. Pinning every pulse to the page's own clock (start time 0) makes a
|
|
93
|
+
// fresh element pick up exactly where the one it replaced was, and keeps the
|
|
94
|
+
// dots on the page beating together. Reduced motion has no animations to pin.
|
|
95
|
+
export const PULSE_NAMES = new Set(['live-pulse', 'pulse', 'needpulse'])
|
|
96
|
+
export function lockPulses(doc) {
|
|
97
|
+
if (!doc || typeof doc.getAnimations !== 'function') return 0
|
|
98
|
+
let n = 0
|
|
99
|
+
for (const a of doc.getAnimations()) {
|
|
100
|
+
if (!PULSE_NAMES.has(a.animationName) || a.startTime === 0) continue
|
|
101
|
+
try { a.startTime = 0; n++ } catch {}
|
|
102
|
+
}
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The "N empty sessions" fold never takes a live session.
|
|
107
|
+
export function foldsAsEmpty(s) { return !!(s && s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking) && !isLiveSession(s)) }
|
|
108
|
+
|
|
109
|
+
// Who sent a message: "You · from <device>". The path (relay, direct,
|
|
110
|
+
// Tailscale) is added only when it explains something: the message didn't arrive.
|
|
111
|
+
const PATHS = { relay: 'through the relay', direct: 'direct', tailscale: 'over Tailscale', peer: 'through another Mac', local: null }
|
|
112
|
+
export function senderLabel(m) {
|
|
113
|
+
if (!m) return ''
|
|
114
|
+
if (m.from === 'claude') return 'Claude'
|
|
115
|
+
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
116
|
+
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
117
|
+
if (m.history && !m.by) return 'You · board'
|
|
118
|
+
const by = m.by || {}
|
|
119
|
+
let device = by.device || null
|
|
120
|
+
if (!device && by.transport === 'local') device = 'this Mac'
|
|
121
|
+
if (!device && m.where) { // older entries: just the where line
|
|
122
|
+
const w = String(m.where)
|
|
123
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
124
|
+
}
|
|
125
|
+
let label = 'You' + (device ? ' · from ' + device : '')
|
|
126
|
+
const path = PATHS[by.transport] || null
|
|
127
|
+
if (path && m.state === 'failed') label += ' (' + path + ')'
|
|
128
|
+
return label
|
|
129
|
+
}
|
|
130
|
+
export function senderTitle(m) {
|
|
131
|
+
const by = (m && m.by) || {}
|
|
132
|
+
const path = PATHS[by.transport] || null
|
|
133
|
+
return [by.device ? 'From ' + by.device : null, path ? 'Path: ' + path : by.transport === 'local' ? 'Path: this Mac' : null].filter(Boolean).join(' · ') || (m && m.where) || ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Stick to the bottom? Only when you are already within `slack` px of it.
|
|
137
|
+
export function atBottom({ scrollTop = 0, scrollHeight = 0, clientHeight = 0 } = {}, slack = 80) {
|
|
138
|
+
return scrollHeight - clientHeight - scrollTop <= slack
|
|
139
|
+
}
|
|
140
|
+
// "Jump to latest ↓": shown whenever a thread that scrolls is away from its bottom, with the
|
|
141
|
+
// count of what came in since when there is any; at the bottom it's hidden and nothing is unseen.
|
|
142
|
+
export function jumpView(box = {}, unseen = 0) {
|
|
143
|
+
const { scrollHeight = 0, clientHeight = 0 } = box || {}
|
|
144
|
+
const away = scrollHeight > clientHeight + 1 && !atBottom(box)
|
|
145
|
+
const n = away && unseen > 0 ? unseen : 0
|
|
146
|
+
return { show: away, unseen: n, count: n > 0 ? ' · ' + n + ' new' : '', label: 'Jump to latest message' + (n > 0 ? ' (' + n + ' new)' : '') }
|
|
147
|
+
}
|
|
148
|
+
// After new content: follow it, or leave the view where it is and count what is new.
|
|
149
|
+
export function scrollDecision({ wasAtBottom, newCount = 0, unseen = 0 }) {
|
|
150
|
+
if (wasAtBottom) return { follow: true, unseen: 0 }
|
|
151
|
+
return { follow: false, unseen: unseen + Math.max(0, newCount) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// #/s/<id>/live: the session page, scrolled to the Live box with its input ready.
|
|
155
|
+
export function liveHref(id) { return '#/s/' + encodeURIComponent(String(id)) + '/live' }
|
|
156
|
+
export function parseLiveRoute(hash) {
|
|
157
|
+
const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
|
|
158
|
+
const p = h.split('/').filter(Boolean)
|
|
159
|
+
return p[0] === 's' && p[1] && p[2] === 'live' ? decodeURIComponent(p[1]) : null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// An unread dot on the Chat button: Claude replied after you last looked.
|
|
163
|
+
export function hasUnread(lastReplyAt, seenAt) {
|
|
164
|
+
const r = Date.parse(lastReplyAt || '')
|
|
165
|
+
if (isNaN(r)) return false
|
|
166
|
+
const s = Date.parse(seenAt || '')
|
|
167
|
+
return isNaN(s) || r > s
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------ the thread's HTML
|
|
171
|
+
|
|
172
|
+
// opts: { esc, ago, md (markdown -> html), now }
|
|
173
|
+
export function entryKey(m) { return String(m.id || '') + ':' + m.from }
|
|
174
|
+
// "Show earlier" at the top of the thread, when older turns are left out.
|
|
175
|
+
export function earlierHtml(n, { esc }) {
|
|
176
|
+
if (!(n > 0)) return ''
|
|
177
|
+
return '<li class="lv-earlier" data-key="earlier" data-sig="' + esc(String(n)) + '" data-older="1"><button type="button" class="btn live-earlier" data-action="live-earlier">Show earlier <span class="faint">(' + esc(String(n)) + (n === 1 ? ' turn' : ' turns') + ')</span></button></li>'
|
|
178
|
+
}
|
|
179
|
+
// A message's time is when it was sent (a clock time today, the date before):
|
|
180
|
+
// a relative "just now" drawn once stays "just now" while the bubble sits there.
|
|
181
|
+
export function sentLabel(iso, now = Date.now()) {
|
|
182
|
+
const t = iso ? Date.parse(iso) : NaN
|
|
183
|
+
if (!Number.isFinite(t)) return ''
|
|
184
|
+
const d = new Date(t), n = new Date(now)
|
|
185
|
+
const today = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
|
|
186
|
+
return today ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
187
|
+
}
|
|
188
|
+
function timeHtml(iso, { esc, now }) {
|
|
189
|
+
const label = sentLabel(iso, now)
|
|
190
|
+
if (!label) return ''
|
|
191
|
+
const full = new Date(Date.parse(iso)).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
192
|
+
return '<time class="faint" datetime="' + esc(new Date(Date.parse(iso)).toISOString()) + '" title="' + esc(full) + '">' + esc(label) + '</time>'
|
|
193
|
+
}
|
|
194
|
+
export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
|
|
195
|
+
const who = senderLabel(m)
|
|
196
|
+
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
197
|
+
let body
|
|
198
|
+
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
199
|
+
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
200
|
+
let foot = ''
|
|
201
|
+
if (m.from === 'claude') {
|
|
202
|
+
// what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
|
|
203
|
+
const st = status || null
|
|
204
|
+
if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
|
|
205
|
+
else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
|
|
206
|
+
if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
|
|
207
|
+
const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
|
|
208
|
+
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>'
|
|
209
|
+
} else if (m.status) {
|
|
210
|
+
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
211
|
+
} else if (m.state) {
|
|
212
|
+
foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
|
|
213
|
+
}
|
|
214
|
+
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
|
|
215
|
+
return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
219
|
+
const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
|
|
220
|
+
|
|
221
|
+
// Under your message: only whether it got there, like a chat app.
|
|
222
|
+
export function deliveryHtml(st, { esc, error = null }) {
|
|
223
|
+
if (!st) return ''
|
|
224
|
+
if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
|
|
225
|
+
const got = st.phase !== 'sent'
|
|
226
|
+
// folded into a turn Claude was already on: the reply above answers it too, no Working bubble of its own
|
|
227
|
+
if (st.folded) return '<div class="lv-tick got lv-folded" role="status" title="It arrived while Claude was working, so Claude took it into that turn">Received <span aria-hidden="true">✓✓</span> · ' + esc(st.phase === 'replied' ? 'Answered with the reply above' : 'Taken into the turn above') + '</div>'
|
|
228
|
+
return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
|
|
232
|
+
export function midClip(s, n = 56) {
|
|
233
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
234
|
+
if (t.length <= n) return t
|
|
235
|
+
const head = Math.ceil((n - 1) * 0.55)
|
|
236
|
+
return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
|
|
237
|
+
}
|
|
238
|
+
const toolLine = (name, detail, { esc, cls = '' }) => '<div class="lv-tool mono' + (cls ? ' ' + cls : '') + '"' + (detail ? ' title="' + esc(name + ' · ' + detail) + '"' : '') + '>' + esc(name) + (detail ? ' · ' + esc(midClip(detail)) : '') + '</div>'
|
|
239
|
+
|
|
240
|
+
// The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
|
|
241
|
+
export function workingHtml(st, { esc, now = Date.now() }) {
|
|
242
|
+
const v = statusView(st, now)
|
|
243
|
+
if (!v) return ''
|
|
244
|
+
let label
|
|
245
|
+
if (st.waiting) label = 'Waiting for your OK'
|
|
246
|
+
else if (v.quiet) label = 'Quiet for a while · check the terminal'
|
|
247
|
+
else if (st.phase === 'sent') label = 'Waiting for the session'
|
|
248
|
+
else if (st.phase === 'received') label = 'Starting'
|
|
249
|
+
else label = 'Working'
|
|
250
|
+
let html = '<div class="lv-status lv-working ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '" role="status"><div class="lvs-line">' + (v.spin ? '<span class="lv-spin" aria-hidden="true"></span>' : '') + '<span class="lvs-label">' + esc(label) + '</span><span class="lvs-sep" aria-hidden="true">·</span><span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div>'
|
|
251
|
+
const recent = (st.ran || []).slice(-3)
|
|
252
|
+
for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
|
|
253
|
+
if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
|
|
254
|
+
else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
|
|
255
|
+
return html + '</div>'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// After the turn: the tools folded into "Ran 3 commands".
|
|
259
|
+
export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
|
|
260
|
+
const ran = (st && st.ran) || []
|
|
261
|
+
if (!ran.length) return ''
|
|
262
|
+
const bash = ran.every((r) => r.tool === 'Bash')
|
|
263
|
+
const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
|
|
264
|
+
const v = statusView(st, now)
|
|
265
|
+
const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
|
|
266
|
+
return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
|
|
270
|
+
export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
|
|
271
|
+
const st = you.status
|
|
272
|
+
const key = 'ans_' + you.id + ':claude'
|
|
273
|
+
let inner
|
|
274
|
+
if (st.phase === 'stopped') inner = '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>' + toolsHtml(st, { esc, now })
|
|
275
|
+
else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
|
|
276
|
+
else inner = workingHtml(st, { esc, now })
|
|
277
|
+
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The whole thread: each message you sent, then Claude's side of that turn.
|
|
281
|
+
export function threadHtml(msgs, opts) {
|
|
282
|
+
const list = msgs || []
|
|
283
|
+
const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
|
|
284
|
+
const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
|
|
285
|
+
let html = ''
|
|
286
|
+
for (const m of list) {
|
|
287
|
+
if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); continue }
|
|
288
|
+
html += entryHtml(m, opts)
|
|
289
|
+
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
|
|
290
|
+
}
|
|
291
|
+
return html
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function statusHtml(st, { esc, now = Date.now(), error = null }) {
|
|
295
|
+
const v = statusView(st, now)
|
|
296
|
+
if (!v) return ''
|
|
297
|
+
const label = st.phase === 'failed' && error ? error : v.label
|
|
298
|
+
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>')
|
|
299
|
+
return '<div class="lv-status ' + v.tone + '" data-since="' + esc(String(st.at || '')) + '"' + (v.final ? ' data-final="' + esc(String(st.endedAt || '')) + '"' : '') + ' role="status">' +
|
|
300
|
+
(st.phase === 'replied' // done: the steps say it all, with the time it took
|
|
301
|
+
? '<div class="lvs-steps">' + steps + '<span class="lvs-time">' + esc(elapsedText(v.elapsedMs)) + '</span></div></div>'
|
|
302
|
+
: '<div class="lvs-steps">' + steps + '</div>' +
|
|
303
|
+
'<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>')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export { ranWithoutAsking }
|
|
307
|
+
|
|
308
|
+
// A content signature, to replace only the entries that changed (the time-ago
|
|
309
|
+
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
310
|
+
function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
311
|
+
export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
312
|
+
|
|
313
|
+
// ------------------------------------------------------------ the DOM keeper
|
|
314
|
+
|
|
315
|
+
// A freshly drawn thread (a page load or refresh) opens at its latest message,
|
|
316
|
+
// and stays there while late layout (fonts, code blocks) settles, unless you
|
|
317
|
+
// scroll up in the meantime.
|
|
318
|
+
export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
|
|
319
|
+
let pinned = -1
|
|
320
|
+
const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
|
|
321
|
+
pin()
|
|
322
|
+
raf(() => { pin(); raf(pin) })
|
|
323
|
+
setTimeout(pin, 250)
|
|
324
|
+
if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Keeps the thread's <ol> across re-renders of the session page.
|
|
328
|
+
export function createLiveKeeper() {
|
|
329
|
+
const kept = { sid: null, wrap: null, unseen: 0 }
|
|
330
|
+
return {
|
|
331
|
+
// Before the page is replaced: hold on to the current thread.
|
|
332
|
+
before(root) {
|
|
333
|
+
const w = root && root.querySelector('.live-thread')
|
|
334
|
+
// a detached element loses its scroll position (WebKit), so remember it and put it back in after()
|
|
335
|
+
const ol = w && w.querySelector('.live-log')
|
|
336
|
+
kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
|
|
337
|
+
if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
|
|
338
|
+
},
|
|
339
|
+
// After: put the old thread back in place of the new one and diff the new entries in.
|
|
340
|
+
after(root) {
|
|
341
|
+
const fresh = root && root.querySelector('.live-thread')
|
|
342
|
+
if (!fresh) { kept.wrap = null; return null }
|
|
343
|
+
const old = kept.wrap
|
|
344
|
+
if (!old || old.dataset.sid !== fresh.dataset.sid) { kept.unseen = 0; const ol = fresh.querySelector('.live-log'); if (ol) pinToBottom(ol); return { thread: fresh, follow: true, first: true } }
|
|
345
|
+
const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
|
|
346
|
+
fresh.replaceWith(old)
|
|
347
|
+
if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
|
|
348
|
+
if (kept.scroll) oldOl.scrollTop = kept.scroll.top
|
|
349
|
+
const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
|
|
350
|
+
const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
|
|
351
|
+
const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
|
|
352
|
+
let added = 0, older = 0
|
|
353
|
+
let prev = null
|
|
354
|
+
let seenKept = false
|
|
355
|
+
for (const li of [...newOl.children]) {
|
|
356
|
+
const cur = have.get(li.dataset.key)
|
|
357
|
+
have.delete(li.dataset.key)
|
|
358
|
+
// added above everything already shown ("Show earlier"): older, not new
|
|
359
|
+
// Claude's "Working" bubble isn't news by itself; the answer that replaces it is
|
|
360
|
+
if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
|
|
361
|
+
if (!cur.dataset.older) seenKept = true
|
|
362
|
+
if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
|
|
363
|
+
}
|
|
364
|
+
for (const li of have.values()) li.remove()
|
|
365
|
+
const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
|
|
366
|
+
kept.unseen = d.unseen
|
|
367
|
+
if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
|
|
368
|
+
else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
|
|
369
|
+
return { thread: old, follow: d.follow, unseen: d.unseen }
|
|
370
|
+
},
|
|
371
|
+
seen() { kept.unseen = 0 },
|
|
372
|
+
get unseen() { return kept.unseen },
|
|
373
|
+
}
|
|
374
|
+
}
|