@leftoff/cli 0.9.8-beta.3 → 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.3",
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.3' // 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.3",
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; }
@@ -2260,3 +2260,46 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2260
2260
  @media (pointer: coarse) and (max-width: 819.98px) { html, body { overscroll-behavior-y: contain; } }
2261
2261
  @media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
2262
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, threadHtml, earlierHtml, 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) +
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) {