@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/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
+ }
@@ -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
+ }