@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.
- package/CHANGELOG.md +3 -2
- package/bin/leftoff.js +370 -365
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +25 -1
- package/ui/app.js +15 -3
- package/ui/live-status.js +6 -5
- package/ui/live-view.js +117 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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),
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
117
|
+
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
107
118
|
} else if (m.state) {
|
|
108
|
-
foot = '<div class="lv-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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 },
|