@leftoff/cli 0.9.9-beta.5 → 0.9.9-beta.7

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.9-beta.5",
3
+ "version": "0.9.9-beta.7",
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
@@ -1509,13 +1509,48 @@ export function fallbackTitle(session) {
1509
1509
  return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
1510
1510
  }
1511
1511
 
1512
- export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. Reply with the title only, no quotes, no punctuation at the end.'
1512
+ // 0.9.9: the title is in the language the user writes in (English when unsure),
1513
+ // one language, one script: a small local model once named an English session
1514
+ // "Ants README笑话" ("joke" in Chinese).
1515
+ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own messages (English if unsure), in one language only: never mix in words or characters from another language.'
1516
+ export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
1513
1517
 
1514
1518
  export function cleanTitle(text) {
1515
- const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1519
+ const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1516
1520
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1517
1521
  }
1518
1522
 
1523
+ // The writing systems in a text's letters (digits, punctuation and emoji have none).
1524
+ // Chinese and Japanese characters count as one ("han").
1525
+ const SCRIPTS = [
1526
+ ['latin', /\p{Script=Latin}/u], ['han', /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}]/u], ['hangul', /\p{Script=Hangul}/u],
1527
+ ['cyrillic', /\p{Script=Cyrillic}/u], ['greek', /\p{Script=Greek}/u], ['arabic', /\p{Script=Arabic}/u], ['hebrew', /\p{Script=Hebrew}/u],
1528
+ ['thai', /\p{Script=Thai}/u], ['devanagari', /\p{Script=Devanagari}/u],
1529
+ ]
1530
+ export function scriptsOf(text) {
1531
+ const out = new Set()
1532
+ for (const ch of String(text || '')) {
1533
+ if (!/\p{L}/u.test(ch)) continue
1534
+ const hit = SCRIPTS.find(([, re]) => re.test(ch))
1535
+ out.add(hit ? hit[0] : 'other')
1536
+ }
1537
+ return out
1538
+ }
1539
+ // Does a generated title keep to the user's writing? Every script in it must
1540
+ // be one the user wrote in; Latin letters are always fine (file names, code,
1541
+ // "README"). A title that brings in another script is dropped for the fallback.
1542
+ export function titleFitsUser(title, userText) {
1543
+ const mine = scriptsOf(userText)
1544
+ for (const sc of scriptsOf(title)) if (sc !== 'latin' && !mine.has(sc)) return false
1545
+ return true
1546
+ }
1547
+ // The line under a title prompt that pins the language: written in Latin
1548
+ // letters only, the title is too (what small models drift from).
1549
+ export function titleLanguageLine(userText) {
1550
+ const mine = scriptsOf(userText)
1551
+ return [...mine].some((sc) => sc !== 'latin') ? 'Title language: the language of the messages above.' : 'Title language: the language of the messages above (English if unsure), Latin letters only.'
1552
+ }
1553
+
1519
1554
  // ---------------------------------------------------------------- catch me up (v0.5.0)
1520
1555
 
1521
1556
  // A model reply -> its bullet lines (at most max), or null when fewer than two.
@@ -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.9-beta.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.7' // 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.9-beta.5",
3
+ "version": "0.9.9-beta.7",
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
@@ -182,6 +182,20 @@ h2 .count { color: var(--faint); font-weight: 500; }
182
182
  .fact { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; min-width: 0; }
183
183
  .fact .k { color: var(--faint); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; }
184
184
  .fact .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
+ .fact .k, .fact .v { display: block; }
186
+ /* 0.9.9: a card with an id or a path is a button: tap copies the whole value, which is its tip */
187
+ .fact.copyable { position: relative; width: 100%; min-height: 44px; text-align: left; cursor: pointer; font: inherit; color: inherit; outline: none; -webkit-tap-highlight-color: transparent; transition: border-color .12s, background .12s; }
188
+ .fact.copyable .k { display: flex; align-items: center; gap: 6px; }
189
+ .fact-ic { display: inline-grid; margin-left: auto; color: var(--faint); opacity: .55; transition: opacity .12s, color .12s; }
190
+ .fact-ic svg { width: 13px; height: 13px; }
191
+ @media (hover: hover) { .fact.copyable:hover { border-color: var(--line-strong); } .fact.copyable:hover .fact-ic { opacity: 1; color: var(--text2); } }
192
+ .fact.copyable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
193
+ .fact.copyable:focus-visible .fact-ic { opacity: 1; }
194
+ .fact.copyable.copied { border-color: var(--accent); }
195
+ .fact.copyable.copied .fact-ic { opacity: 1; color: var(--accent); }
196
+ .fact.copyable[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 20; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font: 500 12px/1.35 var(--mono); text-transform: none; letter-spacing: normal; white-space: normal; overflow-wrap: anywhere; pointer-events: none; }
197
+ @media (hover: hover) { .fact.copyable[data-tip]:hover::after { display: block; } }
198
+ .fact.copyable[data-tip]:focus-visible::after, .fact.copyable.copied[data-tip]::after { display: block; }
185
199
  .strip { display: flex; gap: 3px; margin: 8px 0 4px; flex-wrap: wrap; }
186
200
  .strip span { font-size: 11px; padding: 2px 8px; border-radius: 6px; }
187
201
  .timeline { position: relative; margin-left: 8px; padding-left: 22px; border-left: 2px solid var(--line); }
@@ -2896,8 +2910,8 @@ del.gd-x { background: var(--gd-del-word); }
2896
2910
  the restore banner): the words use the whole width, the buttons a row of
2897
2911
  their own below, 44px tall */
2898
2912
  @media (max-width: 640px) {
2899
- .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow { flex: 1 1 100%; }
2900
- .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x) { flex: 1 1 0; justify-content: center; min-height: 44px; }
2913
+ .start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
2914
+ .start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
2901
2915
  .restore-ask > .rs-m { flex: 1 1 100%; }
2902
2916
  }
2903
2917
 
@@ -2932,9 +2946,41 @@ del.gd-x { background: var(--gd-del-word); }
2932
2946
  .sb-foot { display: flex; justify-content: flex-end; }
2933
2947
  .sb-foot .btn { min-height: 44px; }
2934
2948
  .sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2949
+ /* Go to a folder: type a path you know */
2950
+ .sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
2951
+ .sb-path-l { font-size: 12px; color: var(--muted); }
2952
+ .sb-path-row { display: flex; gap: 8px; min-width: 0; }
2953
+ .sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
2954
+ .sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2955
+ .sb-path-in[aria-invalid="true"] { border-color: var(--red); }
2956
+ .sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
2957
+ .sb-path-err { margin: 0; }
2935
2958
  .start-custom .start-opt-main { white-space: normal; }
2936
2959
  @media (max-width: 640px) {
2937
2960
  .start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
2938
2961
  .sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
2939
2962
  .sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
2940
2963
  }
2964
+
2965
+ /* 0.9.9 asked at pairing time: may other devices start sessions here? */
2966
+ .pair-ask .grow { min-width: 0; }
2967
+ @media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
2968
+
2969
+ /* 0.9.9 a setting that saves on change says so beside it, then fades */
2970
+ .saved-mark { display: inline-block; margin-left: 10px; font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--green, var(--accent)); transition: opacity .35s ease; }
2971
+ .saved-mark.bad { color: var(--red); font-weight: 400; white-space: normal; }
2972
+ .saved-mark.gone { opacity: 0; }
2973
+ @media (prefers-reduced-motion: reduce) { .saved-mark { transition: none; } }
2974
+
2975
+ /* 0.9.9 settings kept on one Mac only: "🖥️ studio only" beside the label (a styled tip,
2976
+ never title=), and a locked control (a lock, aria-disabled) when you're not at that Mac */
2977
+ .local-pill { position: relative; display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: 1px; cursor: default; }
2978
+ .local-pill::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(240px, 70vw); padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: normal; pointer-events: none; z-index: 20; }
2979
+ @media (hover: hover) { .local-pill:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2980
+ .local-pill.show::after { display: block; }
2981
+ @media (prefers-reduced-motion: reduce) { .local-pill::after { animation: none !important; } }
2982
+ .local-pill .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
2983
+ .form label.check.locked { color: var(--muted); cursor: not-allowed; }
2984
+ .form label.check.locked input { cursor: not-allowed; }
2985
+ .form label.check .lock { display: inline-flex; color: var(--muted); }
2986
+ .form label.check .lock .ic { width: 14px; height: 14px; }
package/ui/app.js CHANGED
@@ -16,9 +16,10 @@ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
16
16
  import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
17
17
  import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
18
18
  import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
- import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget } from './start.js' // 0.9.9 New session
19
+ import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
20
20
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
21
21
  import { wireTips } from './tips.js'
22
+ import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
22
23
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
23
24
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
24
25
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
@@ -27,7 +28,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
27
28
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
28
29
  import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
29
30
  import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
30
- import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
31
+ import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted } from './live-view.js' // 0.9.8 Live link polish
31
32
  import { statusView, elapsedText } from './live-status.js'
32
33
  import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
33
34
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
@@ -841,6 +842,7 @@ async function viewBoard() {
841
842
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
842
843
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
843
844
  html += syncDetails() + staleLine(sessions)
845
+ html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
844
846
  html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
845
847
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
846
848
  const needs = needsItems(sessions)
@@ -1496,10 +1498,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
1496
1498
  html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
1497
1499
  if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
1498
1500
  html += '<div class="facts">' +
1499
- 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)) +
1500
- 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) +
1501
- fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1502
- fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
1501
+ fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1502
+ 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, '', { copy: s.id, what: 'session id' }) +
1503
+ fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
1504
+ fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
1503
1505
  if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1504
1506
  html += parkRow(s)
1505
1507
  html += spacePicker(s, spv, 'session', s.id)
@@ -1560,7 +1562,8 @@ async function liveHow() {
1560
1562
  return state.liveHow
1561
1563
  }
1562
1564
  // Does this session's page show a Live thread (and so fetch one)?
1563
- const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1565
+ // (0.9.9: a session Leftoff started reports as "hooks" but always has its thread: before, its page never fetched it and read "Nothing sent yet")
1566
+ const liveWanted = (s) => liveThreadWanted(s)
1564
1567
  // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1565
1568
  // time of the last good answer decides "Reconnecting…" on a pending bubble).
1566
1569
  async function fetchLive(id, { timeoutMs } = {}) {
@@ -1572,9 +1575,9 @@ async function fetchLive(id, { timeoutMs } = {}) {
1572
1575
  } catch (e) { return { messages: [], error: e.message } }
1573
1576
  }
1574
1577
  function liveBoxHtml(s, v) {
1575
- if (s.agent || s.codingAgent === 'codex' || (s.capture === 'hooks' && !s.headless)) return '' // 0.9.9: one Leftoff started talks Live whatever reports it
1576
- if (!isLive(s)) {
1577
- if (s.endedAt || s.live === 'closed') return ''
1578
+ const kind = liveBoxKind(s) // 0.9.9: one Leftoff started talks Live whatever reports it
1579
+ if (kind === 'none') return ''
1580
+ if (kind === 'how') {
1578
1581
  const how = state.liveHow
1579
1582
  return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1580
1583
  '<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
@@ -1587,6 +1590,14 @@ function liveBoxHtml(s, v) {
1587
1590
  const now = Date.now()
1588
1591
  const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1589
1592
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1593
+ // a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
1594
+ if (kind === 'readonly') {
1595
+ if (!msgs.length) return ''
1596
+ 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">Not running. Resume it to send more.</span></div>' +
1597
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
1598
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1599
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
1600
+ }
1590
1601
  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>' +
1591
1602
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1592
1603
  earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
@@ -2229,7 +2240,8 @@ function spacePicker(s, spv, kind, key) {
2229
2240
  options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
2230
2241
  '<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
2231
2242
  }
2232
- function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
2243
+ // (0.9.9: ui/facts.js; an id or a path copies on tap, its whole value in a data-tip)
2244
+ function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
2233
2245
 
2234
2246
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
2235
2247
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
@@ -3027,7 +3039,8 @@ function anywhereSection(r, adv = []) {
3027
3039
  '<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
3028
3040
  // No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
3029
3041
  if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
3030
- h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
3042
+ h += '<label for="f-anywhere">Use from anywhere' + localPillHtml({ machine: r.machine, emoji: r.machine ? mEmoji(r.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>On for this Mac (' + esc(r.machine || '') + ')</span></label>' +
3043
+ (local ? '' : '<div class="hint local-hint">Change on ' + esc(r.machine || 'that Mac') + '.</div>')
3031
3044
  // The name: up top until there is one, then Advanced (0.6.6).
3032
3045
  const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
3033
3046
  (local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
@@ -3391,14 +3404,14 @@ async function viewSettings() {
3391
3404
  '<label data-row="model">Model</label><div data-row="model" class="models" id="f-models-list" role="radiogroup" aria-label="Model"><div class="nocp">Loading…</div></div>' +
3392
3405
  '<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
3393
3406
  '<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
3394
- '<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
3407
+ '<label for="f-key" data-row="key">API key' + localPillHtml({ machine: c.machineResolved, emoji: c.machineResolved ? mEmoji(c.machineResolved) : '', esc }) + '</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
3395
3408
  '<div class="hint" data-row="key" id="f-key-hint"></div>' +
3396
3409
  summariesSection(c) +
3397
3410
  advanced('models', lengthExampleHtml(c)),
3398
3411
  phone: phonePane(relay, remote, unavailable),
3399
3412
  security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
3400
3413
  '<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone &amp; remote</a> is on.</div>') +
3401
- startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer) }) +
3414
+ startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
3402
3415
  // a changed machine key needs a click here, so Other machines stays up top then
3403
3416
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
3404
3417
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
@@ -3602,9 +3615,10 @@ function machinesSection(p) {
3602
3615
  if (!p || p.off) return ''
3603
3616
  const local = p.viewer && p.viewer.local
3604
3617
  let h = '<div class="section" id="machines">Other machines</div>' +
3605
- '<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow other machines to answer and Go here</span></label>' +
3618
+ '<label for="f-peer-allow">Answer and Go' + localPillHtml({ machine: p.machine, emoji: p.machine ? mEmoji(p.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow other machines to answer and Go here</span></label>' +
3606
3619
  '<div class="hint">Off by default. On, a Mac that shares this folder can answer this Mac’s permission prompts and questions and press Go, from its own board. Each command is signed with that Mac’s key from the folder, encrypted, and expires in a minute. ' +
3607
- (p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>'
3620
+ (p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>' +
3621
+ (local ? '' : '<div class="hint local-hint">Change on ' + esc(p.machine || 'that Mac') + '.</div>')
3608
3622
  h += '<div class="full">' + protectHtml('machines') + '</div>'
3609
3623
  if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
3610
3624
  h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
@@ -4176,23 +4190,71 @@ async function viewNewSession() {
4176
4190
  const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
4177
4191
  $app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
4178
4192
  fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
4193
+ { const pi = $app.querySelector('.sb-path-in'); if (pi && startUi.browse && startUi.browse.typed && pi.value !== startUi.browse.typed) pi.value = startUi.browse.typed } // what you were typing survives a redraw
4179
4194
  const ta = $app.querySelector('.start-text')
4180
4195
  if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
4181
4196
  }
4182
4197
  // Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
4183
- async function startBrowse(machine, p) {
4198
+ async function startBrowse(machine, p, { typed = false } = {}) {
4184
4199
  const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
4185
- startUi.browse = { ...(startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}), machine, loading: true, error: null }
4200
+ const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
4201
+ startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
4186
4202
  if (state.route && state.route.name === 'new') render()
4187
4203
  try {
4188
4204
  const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
4189
4205
  if (seq !== startUi.browseSeq || !startUi.browse) return
4190
- startUi.browse = { machine, loading: false, error: null, data: d }
4206
+ startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
4191
4207
  } catch (e) {
4192
4208
  if (seq !== startUi.browseSeq || !startUi.browse) return
4193
- startUi.browse = { ...startUi.browse, loading: false, error: e.message }
4209
+ // a typed path that isn't one you can open: said under the box, and the folder you were in stays
4210
+ startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
4194
4211
  }
4195
- if (state.route && state.route.name === 'new') { render(); const f = $app.querySelector('.sb-crumb[aria-current]'); if (f && startUi.browseFocus) f.focus({ preventScroll: true }) }
4212
+ if (state.route && state.route.name === 'new') {
4213
+ await render() // the page re-reads before it redraws: focus after
4214
+ const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
4215
+ const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
4216
+ if (f) f.focus({ preventScroll: true })
4217
+ }
4218
+ }
4219
+ // "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
4220
+ document.addEventListener('submit', (ev) => {
4221
+ const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
4222
+ if (!f) return
4223
+ ev.preventDefault()
4224
+ const b = startUi.browse, inp = f.querySelector('.sb-path-in')
4225
+ const want = typedPath(inp ? inp.value : '')
4226
+ if (!b) return
4227
+ if (!want) { startUi.browse = { ...b, pathError: 'Type a folder, like ~/code/web.' }; render().then(() => { const pi = $app.querySelector('.sb-path-in'); if (pi) pi.focus() }); return }
4228
+ startUi.browseFocus = true
4229
+ startBrowse(b.machine, want, { typed: true })
4230
+ })
4231
+ document.addEventListener('input', (ev) => {
4232
+ if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
4233
+ startUi.browse.typed = ev.target.value // kept across the board's redraws
4234
+ })
4235
+ // The per-Mac pill sits inside its setting's <label>: a tap on it shows its tip
4236
+ // (touch has no hover) and never flips the setting.
4237
+ document.addEventListener('click', (ev) => {
4238
+ const pill = ev.target && ev.target.closest && ev.target.closest('.local-pill')
4239
+ for (const x of document.querySelectorAll('.local-pill.show')) if (x !== pill) x.classList.remove('show')
4240
+ if (!pill) return
4241
+ ev.preventDefault()
4242
+ pill.classList.toggle('show')
4243
+ }, true)
4244
+ // 0.9.9: a setting that saves the moment you change it (no Save button) says
4245
+ // so beside it: "Saved ✓" for a moment, or what went wrong, inline.
4246
+ const savedTimers = new Map()
4247
+ function markSaved(id, err = null) {
4248
+ const el = document.getElementById(id)
4249
+ const row = el && (el.closest('label.check') || el)
4250
+ if (!row) return
4251
+ let m = row.querySelector('.saved-mark')
4252
+ if (!m) { m = document.createElement('span'); m.className = 'saved-mark'; row.appendChild(m) } // right beside the checkbox's words
4253
+ m.classList.toggle('bad', !!err); m.classList.remove('gone')
4254
+ m.setAttribute('role', err ? 'alert' : 'status')
4255
+ m.textContent = err ? String(err) : 'Saved ✓'
4256
+ clearTimeout(savedTimers.get(id))
4257
+ if (!err) savedTimers.set(id, setTimeout(() => { m.classList.add('gone'); setTimeout(() => { if (m.classList.contains('gone')) m.remove() }, 400) }, 2000))
4196
4258
  }
4197
4259
  // Claude Code's own words when a session it ran stopped with an error: text, never markup.
4198
4260
  const headlessErrors = new Map()
@@ -5202,6 +5264,15 @@ document.addEventListener('click', async (ev) => {
5202
5264
  const id = btn.dataset.id || (card && card.dataset.id)
5203
5265
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
5204
5266
  try {
5267
+ // 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
5268
+ if (action === 'copy-fact') {
5269
+ const ok = await copy(btn.dataset.copy)
5270
+ if (ok) {
5271
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.dataset.tip = 'Copied'
5272
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tip = btn.dataset.copy }, 1500)
5273
+ }
5274
+ return
5275
+ }
5205
5276
  if (action === 'copy-cmd') {
5206
5277
  const ok = await copy(btn.dataset.cmd)
5207
5278
  if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
@@ -5407,6 +5478,17 @@ document.addEventListener('click', async (ev) => {
5407
5478
  return
5408
5479
  }
5409
5480
  if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
5481
+ if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
5482
+ if (startUi.pairBusy) return
5483
+ startUi.pairBusy = true
5484
+ try {
5485
+ const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
5486
+ toast(r.answered === 'yes' ? 'Other devices can start sessions here' + (r.trustTurnedOn ? ' (Trust my paired devices is on too)' : '') : 'OK, they’ll ask here first', 4000)
5487
+ } catch (e) { toast(e.message, 5000) }
5488
+ startUi.pairBusy = false
5489
+ try { state.starts = await api('GET', '/v1/starts') } catch {}
5490
+ return render()
5491
+ }
5410
5492
  if (action === 'start-again') { startUi.ask = null; return render() }
5411
5493
  if (action === 'start-send') return startSend(btn)
5412
5494
  if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
@@ -6154,7 +6236,7 @@ document.addEventListener('submit', async (ev) => {
6154
6236
 
6155
6237
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6156
6238
  let pending = null
6157
- const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value)
6239
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('sb-path-in') && document.activeElement.value)
6158
6240
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6159
6241
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6160
6242
  function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
@@ -6194,13 +6276,17 @@ document.addEventListener('change', async (ev) => {
6194
6276
  }
6195
6277
  if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
6196
6278
  const cb = ev.target
6197
- try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
6198
- return viewSettings()
6279
+ let err = null
6280
+ try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
6281
+ await viewSettings()
6282
+ return markSaved('f-start-allow', err)
6199
6283
  }
6200
6284
  if (ev.target.id === 'f-peer-allow') {
6201
6285
  const cb = ev.target
6202
- try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
6203
- return viewSettings()
6286
+ let err = null
6287
+ try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
6288
+ await viewSettings()
6289
+ return markSaved('f-peer-allow', err)
6204
6290
  }
6205
6291
  if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
6206
6292
  const cb = ev.target, done = cb.checked, li = cb.closest('li')
package/ui/facts.js ADDED
@@ -0,0 +1,34 @@
1
+ // 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
2
+ // Pure (the unit tests read it). A card whose value is an id or a path is a
3
+ // button: tap or click copies the whole value (the toast says it), the whole
4
+ // value is its data-tip, and it is a 44px target. A long path shows its start
5
+ // and its end, the part that tells worktrees apart.
6
+ import { icon } from './icons.js'
7
+ import { midClip } from './live-view.js'
8
+
9
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
10
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
11
+
12
+ // factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
13
+ // copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
14
+ // shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
15
+ // A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
16
+ export function clipPath(p, n = 34) {
17
+ const t = String(p == null ? '' : p)
18
+ if (t.length <= n) return t
19
+ const i = t.replace(/\/+$/, '').lastIndexOf('/')
20
+ const last = i >= 0 ? t.slice(i + 1) : ''
21
+ if (!last || last.length > n - 8) return midClip(t, n)
22
+ return t.slice(0, n - last.length - 2) + '…/' + last
23
+ }
24
+
25
+ export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
26
+ const value = String(v == null ? '' : v)
27
+ const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
28
+ if (copy == null || copy === '') {
29
+ return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
30
+ }
31
+ const full = String(copy)
32
+ return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
33
+ '<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
34
+ }
package/ui/icons.js CHANGED
@@ -17,6 +17,7 @@ export const ICONS = {
17
17
  "qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
18
18
  "refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
19
19
  "check": "<path d=\"M20 6 9 17l-5-5\"/>",
20
+ "lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
20
21
  "folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
21
22
  "bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
22
23
  "x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
package/ui/live-view.js CHANGED
@@ -9,6 +9,23 @@ import { parseAttachedLines } from './attach.js'
9
9
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
10
10
  export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
11
11
 
12
+ // 0.9.9: which Live box a session's page shows, and so whether it fetches the
13
+ // thread. A session Leftoff started (headless) always has one, whatever its
14
+ // capture says ("hooks": its card came from the daemon before Claude's first
15
+ // hook): its first message and the reply are in it, while it runs and after
16
+ // it stops. Another hooks-only session has no Live at all; any other one has
17
+ // a thread while it is live-linked and the how-to otherwise.
18
+ // -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
19
+ export function liveBoxKind(s) {
20
+ if (!s || s.agent || s.codingAgent === 'codex') return 'none'
21
+ if (s.capture === 'hooks' && !s.headless) return 'none'
22
+ if (s.liveLink && s.liveLink.on) return 'thread'
23
+ if (s.headless) return 'readonly'
24
+ if (s.endedAt || s.live === 'closed') return 'none'
25
+ return 'how'
26
+ }
27
+ export const liveThreadWanted = (s) => { const k = liveBoxKind(s); return k === 'thread' || k === 'readonly' }
28
+
12
29
  // The name a session goes by. A live session is never "Untitled": until it has
13
30
  // a title (its first prompt, Claude Code's /rename, a generated one) it goes by
14
31
  // its repo and branch.