@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.
- package/CHANGELOG.md +2 -0
- package/bin/leftoff.js +311 -311
- 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 +44 -1
- package/ui/app.js +18 -13
- package/ui/live-view.js +33 -1
|
@@ -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; }
|
|
@@ -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
|
-
|
|
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) +
|
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) {
|