@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.2",
3
+ "version": "0.9.8-beta.3",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "Joel"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.8-beta.2' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.3' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.8-beta.2",
3
+ "version": "0.9.8-beta.3",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -2086,6 +2086,29 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2086
2086
  .lvs-time { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--muted); }
2087
2087
  .lv-spin { width: 12px; height: 12px; flex: none; border-radius: 50%; border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent); border-top-color: var(--accent); animation: spin .8s linear infinite; }
2088
2088
  @media (prefers-reduced-motion: reduce) { .lv-spin { animation: none; border-top-color: color-mix(in srgb, var(--accent) 25%, transparent); background: var(--accent); transform: scale(.6); } }
2089
+ .lv-earlier { list-style: none; display: flex; justify-content: center; margin: 2px 0 10px; }
2090
+ .live-earlier { min-height: 44px; font-size: 13px; }
2091
+ .lv-terminal { width: fit-content; max-width: 88%; margin-left: auto; background: color-mix(in srgb, var(--tint-accent) 50%, var(--card)); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); }
2092
+ .lv-tick { margin-top: 4px; font-size: 11.5px; color: var(--muted); text-align: right; }
2093
+ .lv-tick.got span { color: var(--accent); letter-spacing: -2px; padding-right: 2px; }
2094
+ .lv-tick.bad { color: var(--red); text-align: left; }
2095
+ .lv-working .lvs-line { margin-top: 0; }
2096
+ .lv-pending .lv-working { margin-top: 4px; }
2097
+ .lvs-sep { color: var(--faint); }
2098
+ .lv-working .lvs-time { margin-left: 0; }
2099
+ .lv-tool { font-size: 12px; line-height: 1.45; color: var(--text2); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
2100
+ .lv-tool.done { color: var(--faint); }
2101
+ .lv-tool.now { color: var(--text); }
2102
+ .lv-tools { margin-top: 6px; font-size: 12px; color: var(--muted); }
2103
+ .lv-tools > summary { cursor: pointer; min-height: 32px; display: inline-flex; align-items: center; gap: 4px; list-style: none; }
2104
+ .lv-tools > summary::-webkit-details-marker { display: none; }
2105
+ .lv-tools > summary::before { content: '▸'; font-size: 10px; color: var(--faint); transition: transform .15s; }
2106
+ .lv-tools[open] > summary::before { transform: rotate(90deg); }
2107
+ @media (prefers-reduced-motion: reduce) { .lv-tools > summary::before { transition: none; } }
2108
+ .lv-tools > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
2109
+ .lv-tools[open] > summary { margin-bottom: 2px; }
2110
+ .lv-none { color: var(--muted); font-style: italic; }
2111
+ .lv-pending { width: fit-content; min-width: min(260px, 100%); }
2089
2112
  .live-jump { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); min-height: 44px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--btn-line); background: var(--card); color: var(--text); font: inherit; font-size: 13px; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.18); cursor: pointer; z-index: 2; }
2090
2113
  .live-jump:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2091
2114
  .live-jump-n { font-weight: 500; color: var(--muted); }
@@ -2101,8 +2124,9 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2101
2124
  @media (pointer: coarse), (max-width: 600px) {
2102
2125
  .live-row .btn, .restore-ask .btn, .btn.chat-btn { min-height: 44px; min-width: 44px; }
2103
2126
  .need-actions .btn, .deny-form .btn { min-height: 44px; } /* Allow / Deny from the phone */
2127
+ .lv-tools > summary { min-height: 44px; }
2104
2128
  .live-box { padding: 10px; margin-left: -4px; margin-right: -4px; }
2105
- .lv-you, .lv-event { max-width: 92%; }
2129
+ .lv-you, .lv-event, .lv-terminal { max-width: 92%; }
2106
2130
  .live-log { max-height: 60vh; max-height: 60dvh; }
2107
2131
  }
2108
2132
 
package/ui/app.js CHANGED
@@ -19,7 +19,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
19
19
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
20
20
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
21
21
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
22
- import { sessionTitle, cardPills, foldsAsEmpty, entryHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
22
+ import { sessionTitle, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
23
23
  import { statusView, elapsedText } from './live-status.js'
24
24
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
25
25
  const $app = document.getElementById('app')
@@ -1359,7 +1359,8 @@ async function liveSection(s) {
1359
1359
  (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1360
1360
  }
1361
1361
  let v = null
1362
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1362
+ const turns = (state.liveTurns && state.liveTurns[s.id]) || 20 // "Show earlier" adds 20 at a time
1363
+ try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live' + (turns > 20 ? '?turns=' + turns : '')) } catch (e) { v = { messages: [], error: e.message } }
1363
1364
  const msgs = (v && v.messages) || []
1364
1365
  const may = state.mayAnswer !== false
1365
1366
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
@@ -1368,7 +1369,7 @@ async function liveSection(s) {
1368
1369
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1369
1370
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1370
1371
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1371
- msgs.map((m) => entryHtml(m, { esc, ago, md: renderDoc, now })).join('') + '</ol>' +
1372
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
1372
1373
  '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1373
1374
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1374
1375
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
@@ -1413,6 +1414,16 @@ function liveJumpClick(btn) {
1413
1414
  if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1414
1415
  liveKeeper.seen(); liveJumpButton(thread, 0)
1415
1416
  }
1417
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1418
+ async function liveEarlierClick(btn) {
1419
+ const box = btn.closest('.live-box')
1420
+ const sid = box && box.dataset.id
1421
+ if (!sid) return
1422
+ state.liveTurns = state.liveTurns || {}
1423
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1424
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1425
+ await render()
1426
+ }
1416
1427
  // The elapsed time beside "Claude is working…", every second while something is in flight.
1417
1428
  let liveTimer = null
1418
1429
  function liveTick() {
@@ -4306,6 +4317,7 @@ document.addEventListener('click', async (ev) => {
4306
4317
  if (action === 'reply-send') return replyClick(btn)
4307
4318
  if (action === 'live-send') return liveSendClick(btn)
4308
4319
  if (action === 'live-jump') return liveJumpClick(btn)
4320
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4309
4321
  if (action === 'quick-reply') return quickReplyClick(btn)
4310
4322
  if (action === 'need-more') return needMoreClick(btn)
4311
4323
  if (action === 'need-dismiss') return needDismissClick(btn)
package/ui/live-status.js CHANGED
@@ -31,18 +31,19 @@ export function stepStatus(st, ev = {}) {
31
31
  case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
32
32
  case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
33
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
34
+ case 'tool': s.phase = 'working'; s.tool = { name: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; s.waiting = null; break
35
35
  case 'tool_done': {
36
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 })
37
+ if (name) s.ran.push({ tool: name, detail: clip(ev.detail || (s.tool && s.tool.detail), 300), asked: !!ev.asked })
38
38
  if (s.ran.length > 20) s.ran = s.ran.slice(-20)
39
39
  s.tool = null; s.waiting = null
40
40
  break
41
41
  }
42
- case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 80) }; break
42
+ case 'ask': s.phase = 'working'; s.waiting = { tool: clip(ev.tool, 40), detail: clip(ev.detail, 300) }; break
43
43
  case 'ask_done': s.waiting = null; break
44
44
  case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
45
45
  case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
46
+ 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
46
47
  default: return s
47
48
  }
48
49
  s.last = at
@@ -62,10 +63,10 @@ export function statusView(st, now = Date.now()) {
62
63
  else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
63
64
  else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
64
65
  } else if (st.phase === 'replied') label = 'Replied'
65
- else if (st.phase === 'stopped') label = 'Stopped without a reply'
66
+ else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
66
67
  else label = 'Didn’t arrive'
67
68
  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
+ const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
69
70
  .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
71
  const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
71
72
  const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
package/ui/live-view.js CHANGED
@@ -43,6 +43,8 @@ export function senderLabel(m) {
43
43
  if (!m) return ''
44
44
  if (m.from === 'claude') return 'Claude'
45
45
  if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
46
+ if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
47
+ if (m.history && !m.by) return 'You · board'
46
48
  const by = m.by || {}
47
49
  let device = by.device || null
48
50
  if (!device && by.transport === 'local') device = 'this Mac'
@@ -91,7 +93,12 @@ export function hasUnread(lastReplyAt, seenAt) {
91
93
 
92
94
  // opts: { esc, ago, md (markdown -> html), now }
93
95
  export function entryKey(m) { return String(m.id || '') + ':' + m.from }
94
- export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
96
+ // "Show earlier" at the top of the thread, when older turns are left out.
97
+ export function earlierHtml(n, { esc }) {
98
+ if (!(n > 0)) return ''
99
+ 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>'
100
+ }
101
+ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
95
102
  const who = senderLabel(m)
96
103
  const title = m.from === 'you' ? senderTitle(m) : ''
97
104
  let body
@@ -99,15 +106,93 @@ export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
99
106
  else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
100
107
  let foot = ''
101
108
  if (m.from === 'claude') {
109
+ // what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
110
+ const st = status || null
111
+ if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
112
+ else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
113
+ if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
102
114
  const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
103
- if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
104
115
  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
116
  } else if (m.status) {
106
- foot = statusHtml(m.status, { esc, now, error: m.errorText })
117
+ foot = deliveryHtml(m.status, { esc, error: m.errorText })
107
118
  } 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>'
119
+ foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
120
+ }
121
+ const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
122
+ 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) + ' <span class="faint">' + esc(ago(m.at)) + '</span></div>' + body + foot + '</li>'
123
+ }
124
+
125
+ const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
126
+ const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
127
+
128
+ // Under your message: only whether it got there, like a chat app.
129
+ export function deliveryHtml(st, { esc, error = null }) {
130
+ if (!st) return ''
131
+ if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
132
+ const got = st.phase !== 'sent'
133
+ 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>'
134
+ }
135
+
136
+ // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
137
+ export function midClip(s, n = 56) {
138
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
139
+ if (t.length <= n) return t
140
+ const head = Math.ceil((n - 1) * 0.55)
141
+ return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
142
+ }
143
+ 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>'
144
+
145
+ // The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
146
+ export function workingHtml(st, { esc, now = Date.now() }) {
147
+ const v = statusView(st, now)
148
+ if (!v) return ''
149
+ let label
150
+ if (st.waiting) label = 'Waiting for your OK'
151
+ else if (v.quiet) label = 'Quiet for a while · check the terminal'
152
+ else if (st.phase === 'sent') label = 'Waiting for the session'
153
+ else if (st.phase === 'received') label = 'Starting'
154
+ else label = 'Working'
155
+ 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>'
156
+ const recent = (st.ran || []).slice(-3)
157
+ for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
158
+ if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
159
+ else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
160
+ return html + '</div>'
161
+ }
162
+
163
+ // After the turn: the tools folded into "Ran 3 commands".
164
+ export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
165
+ const ran = (st && st.ran) || []
166
+ if (!ran.length) return ''
167
+ const bash = ran.every((r) => r.tool === 'Bash')
168
+ const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
169
+ const v = statusView(st, now)
170
+ const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
171
+ return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
172
+ }
173
+
174
+ // Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
175
+ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
176
+ const st = you.status
177
+ const key = 'ans_' + you.id + ':claude'
178
+ let inner
179
+ 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 })
180
+ else inner = workingHtml(st, { esc, now })
181
+ return '<li class="lv lv-claude lv-pending' + (st.phase === 'stopped' ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude <span class="faint">' + esc(ago(st.last ? new Date(st.last).toISOString() : you.at)) + '</span></div>' + inner + '</li>'
182
+ }
183
+
184
+ // The whole thread: each message you sent, then Claude's side of that turn.
185
+ export function threadHtml(msgs, opts) {
186
+ const list = msgs || []
187
+ const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
188
+ const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
189
+ let html = ''
190
+ for (const m of list) {
191
+ 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 }
192
+ html += entryHtml(m, opts)
193
+ if (m.status && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
109
194
  }
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>'
195
+ return html
111
196
  }
112
197
 
113
198
  export function statusHtml(st, { esc, now = Date.now(), error = null }) {
@@ -131,6 +216,18 @@ export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status &
131
216
 
132
217
  // ------------------------------------------------------------ the DOM keeper
133
218
 
219
+ // A freshly drawn thread (a page load or refresh) opens at its latest message,
220
+ // and stays there while late layout (fonts, code blocks) settles, unless you
221
+ // scroll up in the meantime.
222
+ export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
223
+ let pinned = -1
224
+ const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
225
+ pin()
226
+ raf(() => { pin(); raf(pin) })
227
+ setTimeout(pin, 250)
228
+ if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
229
+ }
230
+
134
231
  // Keeps the thread's <ol> across re-renders of the session page.
135
232
  export function createLiveKeeper() {
136
233
  const kept = { sid: null, wrap: null, unseen: 0 }
@@ -138,6 +235,9 @@ export function createLiveKeeper() {
138
235
  // Before the page is replaced: hold on to the current thread.
139
236
  before(root) {
140
237
  const w = root && root.querySelector('.live-thread')
238
+ // a detached element loses its scroll position (WebKit), so remember it and put it back in after()
239
+ const ol = w && w.querySelector('.live-log')
240
+ kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
141
241
  if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
142
242
  },
143
243
  // After: put the old thread back in place of the new one and diff the new entries in.
@@ -145,24 +245,31 @@ export function createLiveKeeper() {
145
245
  const fresh = root && root.querySelector('.live-thread')
146
246
  if (!fresh) { kept.wrap = null; return null }
147
247
  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 } }
248
+ 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 } }
149
249
  const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
150
250
  fresh.replaceWith(old)
151
251
  if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
152
- const wasAtBottom = atBottom(oldOl)
252
+ if (kept.scroll) oldOl.scrollTop = kept.scroll.top
253
+ const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
254
+ const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
153
255
  const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
154
- let added = 0
256
+ let added = 0, older = 0
155
257
  let prev = null
258
+ let seenKept = false
156
259
  for (const li of [...newOl.children]) {
157
260
  const cur = have.get(li.dataset.key)
158
261
  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
262
+ // added above everything already shown ("Show earlier"): older, not new
263
+ // Claude's "Working" bubble isn't news by itself; the answer that replaces it is
264
+ if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
265
+ if (!cur.dataset.older) seenKept = true
266
+ if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
161
267
  }
162
268
  for (const li of have.values()) li.remove()
163
269
  const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
164
270
  kept.unseen = d.unseen
165
271
  if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
272
+ else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
166
273
  return { thread: old, follow: d.follow, unseen: d.unseen }
167
274
  },
168
275
  seen() { kept.unseen = 0 },