@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.4
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 +5 -2
- package/bin/leftoff.js +370 -365
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -3
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +69 -2
- package/ui/app.js +32 -15
- package/ui/live-status.js +6 -5
- package/ui/live-view.js +150 -11
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.4",
|
|
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/core.js
CHANGED
|
@@ -547,7 +547,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
547
547
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
548
548
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
549
549
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
550
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink']
|
|
550
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns']
|
|
551
551
|
|
|
552
552
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
553
553
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -800,7 +800,7 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
|
|
|
800
800
|
export function isEmptySession(s, checkpointCount = 0) {
|
|
801
801
|
if (!s) return true
|
|
802
802
|
// 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
|
|
803
|
-
return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
803
|
+
return !(Number(s.turns) > 0) && !(Number(s.transcriptTurns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
804
804
|
!(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
|
|
805
805
|
}
|
|
806
806
|
|
|
@@ -876,7 +876,7 @@ export function applyEvent(s, ev) {
|
|
|
876
876
|
s.needsInput = false
|
|
877
877
|
s.question = null
|
|
878
878
|
s.lastNotification = null
|
|
879
|
-
if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]
|
|
879
|
+
if (d.prompt && (!s.firstPrompt || /^\s*<channel\b/.test(s.firstPrompt))) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]*>/, '').replace(/<\/channel>\s*$/, '').trim(), 300) || s.firstPrompt || null // 0.9.8: a Live link message, without its wrapper (even one the 300-character clip cut short)
|
|
880
880
|
if (d.countTurn === true) s.turns = (s.turns || 0) + 1 // 0.8.1: Codex counts a turn at its prompt when no task_started came first
|
|
881
881
|
if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
|
|
882
882
|
}
|
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.4' // 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.4",
|
|
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
|
@@ -120,7 +120,7 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
120
120
|
.card:hover { border-color: var(--line-strong); }
|
|
121
121
|
.card.needs_input { border-color: color-mix(in srgb, var(--red) 45%, transparent); box-shadow: 0 0 0 1px var(--tint-red) inset, var(--card-shadow); }
|
|
122
122
|
.card .row1 { display: flex; align-items: flex-start; gap: 10px; }
|
|
123
|
-
.card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; }
|
|
123
|
+
.card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; min-width: 0; overflow-wrap: break-word; } /* breaks at the <wbr> after a "/" and at spaces first; inside a word only when one word is wider than the card */
|
|
124
124
|
.card .title a:hover { text-decoration: underline; text-decoration-color: var(--faint); }
|
|
125
125
|
.s-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
126
126
|
.s-title h1 { min-width: 0; }
|
|
@@ -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
|
|
|
@@ -2236,3 +2260,46 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2236
2260
|
@media (pointer: coarse) and (max-width: 819.98px) { html, body { overscroll-behavior-y: contain; } }
|
|
2237
2261
|
@media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
|
|
2238
2262
|
@keyframes lo-pulse { 50% { opacity: .35; } }
|
|
2263
|
+
|
|
2264
|
+
/* 0.9.8: the session page's header. The title takes the room that's left and
|
|
2265
|
+
breaks at " · " and after "/" (titleHtml's <wbr>), never mid-word unless one
|
|
2266
|
+
word is wider than the page; the buttons are one group that drops under the
|
|
2267
|
+
title as a whole when it doesn't fit beside it. Every button: one line, 44px,
|
|
2268
|
+
an icon and a short label; the primary ones (Checkpoint, Resume) are tinted,
|
|
2269
|
+
not filled, so they don't shout over the rest. */
|
|
2270
|
+
.pagehead.s-head { flex-wrap: wrap; align-items: flex-start; column-gap: 20px; row-gap: 12px; }
|
|
2271
|
+
.pagehead.s-head > .s-head-main { flex: 1 1 28rem; min-width: 0; } /* under ~28rem beside the buttons, the buttons move down instead */
|
|
2272
|
+
.s-head h1 { overflow-wrap: break-word; line-height: 1.3; }
|
|
2273
|
+
.pagehead.s-head > .s-actions { margin-left: 0; flex: 0 1 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; }
|
|
2274
|
+
.s-actions .btn { height: 44px; min-height: 44px; padding: 0 14px; gap: 7px; font-size: 13.5px; line-height: 1; white-space: nowrap; flex: none; justify-content: center; }
|
|
2275
|
+
.s-actions .btn .ic { width: 16px; height: 16px; flex: none; margin: 0; }
|
|
2276
|
+
.s-actions .btn.icon-btn { width: 44px; padding: 0; }
|
|
2277
|
+
.s-actions .more-wrap { display: inline-flex; }
|
|
2278
|
+
.s-actions .btn.primary { background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--btn-line)); }
|
|
2279
|
+
.s-actions .btn.primary:hover { filter: none; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: var(--accent); }
|
|
2280
|
+
.s-actions .btn.primary:active { filter: none; background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
2281
|
+
.s-actions .split { flex: none; display: inline-flex; align-items: stretch; height: 44px; }
|
|
2282
|
+
.s-actions .split .btn { height: 44px; }
|
|
2283
|
+
.s-actions .split .btn.split-more { width: 38px; min-width: 38px; padding: 0; margin-left: 0; border-left: 1px solid color-mix(in srgb, var(--accent) 30%, var(--btn-line)); }
|
|
2284
|
+
.s-actions .split .btn.split-main { border-right-width: 0; }
|
|
2285
|
+
.s-actions .split.is-writing .btn:disabled { opacity: .85; border-style: solid; background: var(--tint-accent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--btn-line)); }
|
|
2286
|
+
.s-actions .split.is-writing .spin { border-color: color-mix(in srgb, var(--accent) 30%, transparent); border-top-color: var(--accent); }
|
|
2287
|
+
@media (max-width: 640px) {
|
|
2288
|
+
/* a phone: the buttons are a row under the title that wraps, each part of a row sharing its width */
|
|
2289
|
+
.pagehead.s-head > .s-actions { flex: 1 1 100%; }
|
|
2290
|
+
.s-actions > .btn, .s-actions > .split { flex: 1 1 auto; }
|
|
2291
|
+
.s-actions .split .split-main { flex: 1 1 auto; }
|
|
2292
|
+
.s-actions > .more-wrap { flex: none; }
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/* 0.9.8: a card's row (Go, Chat, copy, ⋯) and Catch me up's row: one line each,
|
|
2296
|
+
all the same height (44px on touch). */
|
|
2297
|
+
.card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { white-space: nowrap; height: 30px; min-height: 30px; padding-top: 0; padding-bottom: 0; line-height: 28px; }
|
|
2298
|
+
.card > .actions-row > .btn.icon-btn { width: 32px; line-height: 1; }
|
|
2299
|
+
.card > .actions-row > .btn.chat-btn { min-width: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding-top: 0; padding-bottom: 0; overflow: visible; }
|
|
2300
|
+
.cu-s .actions-row > .btn { display: inline-flex; align-items: center; }
|
|
2301
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2302
|
+
.card > .actions-row > .btn, .card > .actions-row > .go-off > .btn, .cu-s .actions-row > .btn { height: 44px; min-height: 44px; line-height: 42px; }
|
|
2303
|
+
.card > .actions-row > .btn.icon-btn { width: 44px; }
|
|
2304
|
+
.card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
|
|
2305
|
+
}
|
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, titleIsWhere, titleHtml, 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')
|
|
@@ -184,11 +184,11 @@ function wtChips(s) {
|
|
|
184
184
|
return h
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
function where(s) {
|
|
187
|
+
function where(s, { repo = true } = {}) {
|
|
188
188
|
if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
|
|
189
189
|
const branch = s.repo && s.repo.branch
|
|
190
190
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
191
|
-
return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
191
|
+
return projChip(s) + (repo ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' : '') + wtChips(s) +
|
|
192
192
|
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -387,9 +387,13 @@ function liveNote(s) {
|
|
|
387
387
|
if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
|
|
388
388
|
return s.live === 'open' ? 'open' : ''
|
|
389
389
|
}
|
|
390
|
-
|
|
390
|
+
// 0.9.8: { page: true } is the session page's header, where a Go that can't run
|
|
391
|
+
// is left out (its tooltip was the only place the reason showed, and the grey
|
|
392
|
+
// box read as broken); a card keeps the dimmed Go and its short why.
|
|
393
|
+
function goButton(s, { page = false } = {}) {
|
|
391
394
|
if (s.live === 'closed') return ''
|
|
392
395
|
const j = s.jump || {}
|
|
396
|
+
if (page && (state.viewerLocal === false || !j.supported)) return ''
|
|
393
397
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
394
398
|
if (!s.isLocal) {
|
|
395
399
|
if (!j.peer) return ''
|
|
@@ -557,7 +561,7 @@ function checkpointSplit(s) {
|
|
|
557
561
|
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
558
562
|
}
|
|
559
563
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
560
|
-
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '"
|
|
564
|
+
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '" aria-label="Checkpoint now">' + icon('bookmark-plus') + 'Checkpoint</button>' +
|
|
561
565
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
562
566
|
}
|
|
563
567
|
function openLengthMenu(btn, id) {
|
|
@@ -581,10 +585,10 @@ function card(s) {
|
|
|
581
585
|
const nb = s.needsYou ? cardBadge(s.needsYou) : ''
|
|
582
586
|
return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
|
|
583
587
|
'<div class="row1"><div class="title">' + liveDot(s) +
|
|
584
|
-
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' +
|
|
588
|
+
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
585
589
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
586
590
|
'</div>' + statusPill(s) + liveBadge(s) + '</div>' +
|
|
587
|
-
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
591
|
+
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
588
592
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
589
593
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
590
594
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
@@ -1248,8 +1252,8 @@ async function viewAgentSession(s) {
|
|
|
1248
1252
|
const name = esc(a.display || a.name)
|
|
1249
1253
|
const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
|
|
1250
1254
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1251
|
-
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1252
|
-
'<div class="actions">' + agentReplyButton(a) + '</div></div>'
|
|
1255
|
+
html += '<div class="pagehead s-head"><div class="s-head-main"><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1256
|
+
'<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
|
|
1253
1257
|
// v0.5.6: agent.url only links the name; the reply affordance and its
|
|
1254
1258
|
// words come from the feed (agent.reply, agent.replyHint), or nothing.
|
|
1255
1259
|
html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
|
|
@@ -1279,10 +1283,11 @@ async function viewSession(id) {
|
|
|
1279
1283
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1280
1284
|
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1281
1285
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1282
|
-
html += '<div class="pagehead"><div>' + (canRename
|
|
1283
|
-
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' +
|
|
1284
|
-
: '<h1>' +
|
|
1285
|
-
|
|
1286
|
+
html += '<div class="pagehead s-head"><div class="s-head-main">' + (canRename
|
|
1287
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1288
|
+
: '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>') + '<div class="sub">' + statusPill(s) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
|
|
1289
|
+
// 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
|
|
1290
|
+
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="Copy resume command">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1286
1291
|
html += '<div class="facts">' +
|
|
1287
1292
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1288
1293
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
@@ -1359,7 +1364,8 @@ async function liveSection(s) {
|
|
|
1359
1364
|
(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
1365
|
}
|
|
1361
1366
|
let v = null
|
|
1362
|
-
|
|
1367
|
+
const turns = (state.liveTurns && state.liveTurns[s.id]) || 20 // "Show earlier" adds 20 at a time
|
|
1368
|
+
try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live' + (turns > 20 ? '?turns=' + turns : '')) } catch (e) { v = { messages: [], error: e.message } }
|
|
1363
1369
|
const msgs = (v && v.messages) || []
|
|
1364
1370
|
const may = state.mayAnswer !== false
|
|
1365
1371
|
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
@@ -1368,7 +1374,7 @@ async function liveSection(s) {
|
|
|
1368
1374
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1369
1375
|
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
1376
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1371
|
-
|
|
1377
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
|
|
1372
1378
|
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1373
1379
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1374
1380
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
@@ -1413,6 +1419,16 @@ function liveJumpClick(btn) {
|
|
|
1413
1419
|
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
1414
1420
|
liveKeeper.seen(); liveJumpButton(thread, 0)
|
|
1415
1421
|
}
|
|
1422
|
+
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1423
|
+
async function liveEarlierClick(btn) {
|
|
1424
|
+
const box = btn.closest('.live-box')
|
|
1425
|
+
const sid = box && box.dataset.id
|
|
1426
|
+
if (!sid) return
|
|
1427
|
+
state.liveTurns = state.liveTurns || {}
|
|
1428
|
+
state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
|
|
1429
|
+
btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
|
|
1430
|
+
await render()
|
|
1431
|
+
}
|
|
1416
1432
|
// The elapsed time beside "Claude is working…", every second while something is in flight.
|
|
1417
1433
|
let liveTimer = null
|
|
1418
1434
|
function liveTick() {
|
|
@@ -4306,6 +4322,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4306
4322
|
if (action === 'reply-send') return replyClick(btn)
|
|
4307
4323
|
if (action === 'live-send') return liveSendClick(btn)
|
|
4308
4324
|
if (action === 'live-jump') return liveJumpClick(btn)
|
|
4325
|
+
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
4309
4326
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
4310
4327
|
if (action === 'need-more') return needMoreClick(btn)
|
|
4311
4328
|
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
|
@@ -13,7 +13,12 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
13
13
|
// its repo and branch.
|
|
14
14
|
export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
15
15
|
if (!s) return untitled
|
|
16
|
-
|
|
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
|
|
17
22
|
if (isLiveSession(s)) {
|
|
18
23
|
const repo = s.repoLabel || s.repoName || (s.repo && s.repo.name) || null
|
|
19
24
|
const branch = s.branch || (s.repo && s.repo.branch) || null
|
|
@@ -24,6 +29,33 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
|
|
|
24
29
|
return untitled
|
|
25
30
|
}
|
|
26
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
|
+
|
|
27
59
|
// Which pills a card shows: a live session that is just waiting for you shows
|
|
28
60
|
// one Live pill (never "dormant"); one that is working or needs you shows that too.
|
|
29
61
|
export function cardPills(s) {
|
|
@@ -43,6 +75,8 @@ export function senderLabel(m) {
|
|
|
43
75
|
if (!m) return ''
|
|
44
76
|
if (m.from === 'claude') return 'Claude'
|
|
45
77
|
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
78
|
+
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
79
|
+
if (m.history && !m.by) return 'You · board'
|
|
46
80
|
const by = m.by || {}
|
|
47
81
|
let device = by.device || null
|
|
48
82
|
if (!device && by.transport === 'local') device = 'this Mac'
|
|
@@ -91,7 +125,12 @@ export function hasUnread(lastReplyAt, seenAt) {
|
|
|
91
125
|
|
|
92
126
|
// opts: { esc, ago, md (markdown -> html), now }
|
|
93
127
|
export function entryKey(m) { return String(m.id || '') + ':' + m.from }
|
|
94
|
-
|
|
128
|
+
// "Show earlier" at the top of the thread, when older turns are left out.
|
|
129
|
+
export function earlierHtml(n, { esc }) {
|
|
130
|
+
if (!(n > 0)) return ''
|
|
131
|
+
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>'
|
|
132
|
+
}
|
|
133
|
+
export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
|
|
95
134
|
const who = senderLabel(m)
|
|
96
135
|
const title = m.from === 'you' ? senderTitle(m) : ''
|
|
97
136
|
let body
|
|
@@ -99,15 +138,93 @@ export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
|
|
|
99
138
|
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
100
139
|
let foot = ''
|
|
101
140
|
if (m.from === 'claude') {
|
|
141
|
+
// what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
|
|
142
|
+
const st = status || null
|
|
143
|
+
if (st && !m.done && !FINAL_PHASES.has(st.phase)) foot += workingHtml(st, { esc, now })
|
|
144
|
+
else if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
|
|
145
|
+
if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
|
|
102
146
|
const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
|
|
103
|
-
if (!m.done) foot += '<div class="lv-note">Still writing…</div>'
|
|
104
147
|
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
148
|
} else if (m.status) {
|
|
106
|
-
foot =
|
|
149
|
+
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
107
150
|
} else if (m.state) {
|
|
108
|
-
foot = '<div class="lv-
|
|
151
|
+
foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
|
|
152
|
+
}
|
|
153
|
+
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
|
|
154
|
+
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>'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
158
|
+
const statusSig = (st) => JSON.stringify([st.phase, st.tool, st.waiting, (st.ran || []).length, st.endedAt, st.lost])
|
|
159
|
+
|
|
160
|
+
// Under your message: only whether it got there, like a chat app.
|
|
161
|
+
export function deliveryHtml(st, { esc, error = null }) {
|
|
162
|
+
if (!st) return ''
|
|
163
|
+
if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
|
|
164
|
+
const got = st.phase !== 'sent'
|
|
165
|
+
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>'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
|
|
169
|
+
export function midClip(s, n = 56) {
|
|
170
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
171
|
+
if (t.length <= n) return t
|
|
172
|
+
const head = Math.ceil((n - 1) * 0.55)
|
|
173
|
+
return t.slice(0, head) + '…' + t.slice(t.length - (n - 1 - head))
|
|
174
|
+
}
|
|
175
|
+
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>'
|
|
176
|
+
|
|
177
|
+
// The live line on Claude's side: spinner, "Working · 45s", what it is running now, the last few it ran.
|
|
178
|
+
export function workingHtml(st, { esc, now = Date.now() }) {
|
|
179
|
+
const v = statusView(st, now)
|
|
180
|
+
if (!v) return ''
|
|
181
|
+
let label
|
|
182
|
+
if (st.waiting) label = 'Waiting for your OK'
|
|
183
|
+
else if (v.quiet) label = 'Quiet for a while · check the terminal'
|
|
184
|
+
else if (st.phase === 'sent') label = 'Waiting for the session'
|
|
185
|
+
else if (st.phase === 'received') label = 'Starting'
|
|
186
|
+
else label = 'Working'
|
|
187
|
+
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>'
|
|
188
|
+
const recent = (st.ran || []).slice(-3)
|
|
189
|
+
for (const r of recent) html += toolLine(r.tool, r.detail, { esc, cls: 'done' })
|
|
190
|
+
if (st.waiting) html += toolLine(st.waiting.tool || 'a tool', st.waiting.detail, { esc, cls: 'now' })
|
|
191
|
+
else if (st.tool) html += toolLine(st.tool.name, st.tool.detail, { esc, cls: 'now' })
|
|
192
|
+
return html + '</div>'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// After the turn: the tools folded into "Ran 3 commands".
|
|
196
|
+
export function toolsHtml(st, { esc, now = Date.now(), open = false }) {
|
|
197
|
+
const ran = (st && st.ran) || []
|
|
198
|
+
if (!ran.length) return ''
|
|
199
|
+
const bash = ran.every((r) => r.tool === 'Bash')
|
|
200
|
+
const what = bash ? (ran.length === 1 ? 'Ran 1 command' : 'Ran ' + ran.length + ' commands') : (ran.length === 1 ? 'Used 1 tool' : 'Used ' + ran.length + ' tools')
|
|
201
|
+
const v = statusView(st, now)
|
|
202
|
+
const time = v && v.final && st.endedAt ? ' · ' + elapsedText(v.elapsedMs) : ''
|
|
203
|
+
return '<details class="lv-tools"' + (open ? ' open' : '') + '><summary>' + esc(what + time) + '</summary>' + ran.map((r) => toolLine(r.tool, r.detail, { esc })).join('') + '</details>'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Claude's side before its answer lands (or when none came): the same bubble the answer replaces.
|
|
207
|
+
export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
|
|
208
|
+
const st = you.status
|
|
209
|
+
const key = 'ans_' + you.id + ':claude'
|
|
210
|
+
let inner
|
|
211
|
+
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 })
|
|
212
|
+
else inner = workingHtml(st, { esc, now })
|
|
213
|
+
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>'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The whole thread: each message you sent, then Claude's side of that turn.
|
|
217
|
+
export function threadHtml(msgs, opts) {
|
|
218
|
+
const list = msgs || []
|
|
219
|
+
const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
|
|
220
|
+
const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
|
|
221
|
+
let html = ''
|
|
222
|
+
for (const m of list) {
|
|
223
|
+
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 }
|
|
224
|
+
html += entryHtml(m, opts)
|
|
225
|
+
if (m.status && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
|
|
109
226
|
}
|
|
110
|
-
return
|
|
227
|
+
return html
|
|
111
228
|
}
|
|
112
229
|
|
|
113
230
|
export function statusHtml(st, { esc, now = Date.now(), error = null }) {
|
|
@@ -131,6 +248,18 @@ export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status &
|
|
|
131
248
|
|
|
132
249
|
// ------------------------------------------------------------ the DOM keeper
|
|
133
250
|
|
|
251
|
+
// A freshly drawn thread (a page load or refresh) opens at its latest message,
|
|
252
|
+
// and stays there while late layout (fonts, code blocks) settles, unless you
|
|
253
|
+
// scroll up in the meantime.
|
|
254
|
+
export function pinToBottom(ol, { raf = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16)), fonts = (typeof document !== 'undefined' && document.fonts) || null } = {}) {
|
|
255
|
+
let pinned = -1
|
|
256
|
+
const pin = () => { if (pinned >= 0 && ol.scrollTop < pinned - 4) return; ol.scrollTop = ol.scrollHeight; pinned = ol.scrollTop }
|
|
257
|
+
pin()
|
|
258
|
+
raf(() => { pin(); raf(pin) })
|
|
259
|
+
setTimeout(pin, 250)
|
|
260
|
+
if (fonts && fonts.ready && fonts.ready.then) fonts.ready.then(pin, () => {})
|
|
261
|
+
}
|
|
262
|
+
|
|
134
263
|
// Keeps the thread's <ol> across re-renders of the session page.
|
|
135
264
|
export function createLiveKeeper() {
|
|
136
265
|
const kept = { sid: null, wrap: null, unseen: 0 }
|
|
@@ -138,6 +267,9 @@ export function createLiveKeeper() {
|
|
|
138
267
|
// Before the page is replaced: hold on to the current thread.
|
|
139
268
|
before(root) {
|
|
140
269
|
const w = root && root.querySelector('.live-thread')
|
|
270
|
+
// a detached element loses its scroll position (WebKit), so remember it and put it back in after()
|
|
271
|
+
const ol = w && w.querySelector('.live-log')
|
|
272
|
+
kept.scroll = ol ? { top: ol.scrollTop, bottom: atBottom(ol) } : null
|
|
141
273
|
if (w) { kept.wrap = w; kept.sid = w.dataset.sid; w.remove() } else { kept.wrap = null; kept.sid = null }
|
|
142
274
|
},
|
|
143
275
|
// After: put the old thread back in place of the new one and diff the new entries in.
|
|
@@ -145,24 +277,31 @@ export function createLiveKeeper() {
|
|
|
145
277
|
const fresh = root && root.querySelector('.live-thread')
|
|
146
278
|
if (!fresh) { kept.wrap = null; return null }
|
|
147
279
|
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
|
|
280
|
+
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
281
|
const oldOl = old.querySelector('.live-log'), newOl = fresh.querySelector('.live-log')
|
|
150
282
|
fresh.replaceWith(old)
|
|
151
283
|
if (!oldOl || !newOl) { if (newOl && !oldOl) old.replaceWith(fresh); return { thread: oldOl ? old : fresh, follow: true } }
|
|
152
|
-
|
|
284
|
+
if (kept.scroll) oldOl.scrollTop = kept.scroll.top
|
|
285
|
+
const wasAtBottom = kept.scroll ? kept.scroll.bottom : atBottom(oldOl)
|
|
286
|
+
const fromBottom = oldOl.scrollHeight - oldOl.scrollTop
|
|
153
287
|
const have = new Map([...oldOl.children].map((li) => [li.dataset.key, li]))
|
|
154
|
-
let added = 0
|
|
288
|
+
let added = 0, older = 0
|
|
155
289
|
let prev = null
|
|
290
|
+
let seenKept = false
|
|
156
291
|
for (const li of [...newOl.children]) {
|
|
157
292
|
const cur = have.get(li.dataset.key)
|
|
158
293
|
have.delete(li.dataset.key)
|
|
159
|
-
|
|
160
|
-
|
|
294
|
+
// added above everything already shown ("Show earlier"): older, not new
|
|
295
|
+
// Claude's "Working" bubble isn't news by itself; the answer that replaces it is
|
|
296
|
+
if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
|
|
297
|
+
if (!cur.dataset.older) seenKept = true
|
|
298
|
+
if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
|
|
161
299
|
}
|
|
162
300
|
for (const li of have.values()) li.remove()
|
|
163
301
|
const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
|
|
164
302
|
kept.unseen = d.unseen
|
|
165
303
|
if (d.follow) oldOl.scrollTop = oldOl.scrollHeight
|
|
304
|
+
else if (older) oldOl.scrollTop = oldOl.scrollHeight - fromBottom // keep what you were reading in place
|
|
166
305
|
return { thread: old, follow: d.follow, unseen: d.unseen }
|
|
167
306
|
},
|
|
168
307
|
seen() { kept.unseen = 0 },
|