@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.2",
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[^>]*>([\s\S]*?)<\/channel>\s*$/, '$1').trim(), 300) // 0.9.8: a Live link message, without its wrapper
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
  }
@@ -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.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.2",
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, entryHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
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
- function goButton(s) {
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)) + '">Checkpoint now</button>' +
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) + '">' + esc(sessionTitle(s)) + '</a>' +
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">' + esc(sessionTitle(s)) + '</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>'
1284
- : '<h1>' + esc(sessionTitle(s)) + '</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>' : '') + ' &nbsp;' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
1285
- '<div class="actions">' + goButton(s) + (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) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">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
+ 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>' : '') + ' &nbsp;' + 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
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
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
- msgs.map((m) => entryHtml(m, { esc, ago, md: renderDoc, now })).join('') + '</ol>' +
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, 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
@@ -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
- if (s.title) return s.title
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
- export function entryHtml(m, { esc, ago, md, now = Date.now() }) {
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 = statusHtml(m.status, { esc, now, error: m.errorText })
149
+ foot = deliveryHtml(m.status, { esc, error: m.errorText })
107
150
  } 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>'
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 '<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>'
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.scrollTop = ol.scrollHeight; return { thread: fresh, follow: true, first: true } }
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
- const wasAtBottom = atBottom(oldOl)
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
- 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
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 },