@leftoff/cli 0.9.8-beta.11 → 0.9.8-beta.13

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.11",
3
+ "version": "0.9.8-beta.13",
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"
@@ -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.11' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.13' // 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.11",
3
+ "version": "0.9.8-beta.13",
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
@@ -2076,6 +2076,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2076
2076
  .lv-md pre code { background: none; border: 0; padding: 0; }
2077
2077
  .lv-md a { color: var(--accent); text-decoration: underline; }
2078
2078
  .lv-md blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--muted); }
2079
+ .lv-you .lv-md code, .lv-you .lv-md pre { background: color-mix(in srgb, var(--accent) 8%, var(--card)); border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); }
2079
2080
  .lv-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
2080
2081
  /* the status line under a message: Sent → Received → Working → Replied, with a spinner and the time */
2081
2082
  .lv-status { margin-top: 6px; font-size: 12px; color: var(--muted); }
@@ -2586,3 +2587,25 @@ html.pv-open, html.pv-open body { overflow: hidden; }
2586
2587
  /* a chip in the box opens its preview (its × and ↻ stay their own buttons) */
2587
2588
  .att-open { display: flex; align-items: center; gap: inherit; min-width: 0; flex: 1 1 auto; min-height: 44px; padding: 0; margin: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 9px; }
2588
2589
  .att-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2590
+
2591
+ /* "Leftoff updated · Refresh" (0.9.8, ui/update-bar.js): a pill at the top,
2592
+ above the header and the phone's bars (it doesn't move with nav-hide) and
2593
+ under menus, sheets and the previews. Never covers the bottom nav or the composer. */
2594
+ .updatebar { position: fixed; z-index: 35; left: 50%; top: calc(env(safe-area-inset-top) + 8px); transform: translate(-50%, -12px); opacity: 0;
2595
+ display: flex; align-items: center; gap: 4px; max-width: min(520px, calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right))); width: max-content;
2596
+ padding: 4px 4px 4px 14px; border-radius: 26px; background: var(--card2); color: var(--text); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow);
2597
+ font-size: 14px; line-height: 1.3; transition: opacity .2s ease, transform .2s ease; }
2598
+ .updatebar.in { opacity: 1; transform: translate(-50%, 0); }
2599
+ .updatebar[hidden] { display: none; }
2600
+ .updatebar .ub-icon { display: inline-flex; color: var(--accent); flex: none; }
2601
+ .updatebar .ub-icon svg { width: 16px; height: 16px; }
2602
+ .updatebar .ub-text { flex: 1 1 auto; min-width: 0; font-weight: 600; padding: 0 6px 0 4px; overflow-wrap: anywhere; }
2603
+ .updatebar.confirm .ub-text { font-weight: 500; font-size: 13px; color: var(--text2, var(--text)); }
2604
+ .updatebar .ub-go { flex: none; min-height: 44px; padding: 0 16px; border-radius: 22px; border: 0; background: var(--accent); color: var(--on-accent); font: inherit; font-weight: 700; cursor: pointer; white-space: nowrap; }
2605
+ .updatebar .ub-go:disabled { opacity: .7; cursor: default; }
2606
+ .updatebar .ub-x { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: none; color: var(--muted); cursor: pointer; }
2607
+ .updatebar .ub-x svg { width: 18px; height: 18px; }
2608
+ .updatebar .ub-x:hover { color: var(--text); background: var(--bg2); }
2609
+ .updatebar .ub-go:focus-visible, .updatebar .ub-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2610
+ @media (min-width: 700px) { .updatebar { left: calc(50% + var(--rail-w, 0px) / 2); } }
2611
+ @media (prefers-reduced-motion: reduce) { .updatebar { transition: none; } }
package/ui/app.js CHANGED
@@ -17,6 +17,7 @@ import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (
17
17
  import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
18
18
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
19
19
  import { wireTips } from './tips.js'
20
+ import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
20
21
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
21
22
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
22
23
  import { mdInline, renderSection } from './changelog-md.js'
@@ -34,6 +35,10 @@ const $app = document.getElementById('app')
34
35
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
35
36
  if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
36
37
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
38
+ // 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
39
+ const carried = takeCarry()
40
+ if (carried) Object.assign(state.drafts, carried.drafts)
41
+ let updateBar = null
37
42
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
38
43
  const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
39
44
 
@@ -3991,7 +3996,9 @@ async function refreshHealth() {
3991
3996
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3992
3997
  paintMoreDots()
3993
3998
  paintSettingsDots()
3999
+ if (updateBar) updateBar.check(h)
3994
4000
  } catch (e) {
4001
+ if (updateBar) updateBar.check(null) // a failed check changes nothing
3995
4002
  el.className = 'health down'
3996
4003
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
3997
4004
  el.title = el.querySelector('.label').textContent
@@ -5764,7 +5771,46 @@ document.addEventListener('pointerdown', (ev) => {
5764
5771
  if (m && !m.contains(ev.target)) m.open = false
5765
5772
  })
5766
5773
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
5774
+ // 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
5775
+ // files than the ones this page runs. It never reloads by itself; Refresh
5776
+ // carries your drafts over and says first what wouldn't come along.
5777
+ updateBar = mountUpdateBar({
5778
+ icon,
5779
+ health: () => refreshHealth(),
5780
+ risks: () => {
5781
+ let attachments = 0, uploading = 0
5782
+ for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
5783
+ const f = document.activeElement
5784
+ const typing = !!(f && /^(INPUT|TEXTAREA)$/.test(f.tagName) && !f.dataset.draft && !f.readOnly && !/^(checkbox|radio|button|submit|range|color|file|search)$/.test(f.type) && !f.closest('.cmdk, #settingsForm') && String(f.value || '').trim())
5785
+ let unsavedSettings = false
5786
+ try { unsavedSettings = settingsChanged().length > 0 } catch {}
5787
+ return { attachments, uploading, unsavedSettings, typing }
5788
+ },
5789
+ carry: () => {
5790
+ const drafts = { ...state.drafts }
5791
+ for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
5792
+ const f = document.activeElement
5793
+ const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
5794
+ return { drafts, focus }
5795
+ },
5796
+ })
5797
+ // The box you were typing in, if you tabbed from it to the bar.
5798
+ let lastDraftFocus = null
5799
+ document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
5800
+ document.addEventListener('focusout', (ev) => { const t = ev.target; if (t && t.dataset && t.dataset.draft) lastDraftFocus = { key: t.dataset.draft, a: t.selectionStart || 0, b: t.selectionEnd || 0 } })
5801
+ // Back from Refresh: the box you were typing in has its text, and (with a
5802
+ // keyboard and mouse) the cursor where it was.
5803
+ function restoreCarriedFocus(tries = 50) {
5804
+ const f = carried && carried.focus
5805
+ if (!f || isTouchUi(window.matchMedia)) return
5806
+ if (document.activeElement && document.activeElement !== document.body) return // you've moved on
5807
+ const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
5808
+ if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
5809
+ t.focus({ preventScroll: true })
5810
+ try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
5811
+ }
5767
5812
  refreshHealth().then(render).then(firstRunSetup)
5813
+ restoreCarriedFocus()
5768
5814
  connect()
5769
5815
 
5770
5816
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
package/ui/live-view.js CHANGED
@@ -208,8 +208,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
208
208
  if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
209
209
  else if (m.from === 'you') {
210
210
  const a = messageAttachments(m)
211
- body = (a.text || !a.attachments.length ? '<div class="lv-text">' + esc(a.text) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
212
- } else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
211
+ body = (a.text || !a.attachments.length ? '<div class="lv-text lv-md">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
212
+ } else if (m.from === 'terminal') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
213
+ else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
213
214
  let foot = ''
214
215
  if (m.from === 'claude') {
215
216
  // what Claude did for this turn sits with Claude's answer: still working, the tools it ran, the note
@@ -0,0 +1,158 @@
1
+ // 0.9.8: "Leftoff updated · Refresh". An open board (a desktop tab, the
2
+ // iPhone Home Screen app) keeps running the code it loaded with until it's
3
+ // reloaded. The page knows the build it came with (index.html's
4
+ // <meta name="leftoff-ui">, stamped by the Mac that served it, src/ui-build.js)
5
+ // and every health check says what that Mac serves now (`ui`); when they differ
6
+ // a bar offers a one-tap reload. Never on first load, never on its own, and a
7
+ // failed health check changes nothing. The pure parts are unit-tested.
8
+
9
+ export const POLL_MS = 60 * 1000 // a health check at least this often while the page is visible
10
+ export const CARRY_KEY = 'leftoff.reload.carry' // drafts handed to the reloaded page
11
+ export const CARRY_MAX_AGE_MS = 2 * 60 * 1000
12
+
13
+ // The build this page loaded with: { build, machine } from the meta tag, or null.
14
+ export function pageBuild(doc = typeof document !== 'undefined' ? document : null) {
15
+ const m = doc && doc.querySelector && doc.querySelector('meta[name="leftoff-ui"]')
16
+ const build = m && m.getAttribute('content')
17
+ return build ? { build, machine: m.getAttribute('data-machine') || '' } : null
18
+ }
19
+
20
+ // Should the bar show? loaded: { build, machine } (what this page runs);
21
+ // current: a health answer ({ ui, machine }), or null when the check failed;
22
+ // state: { showing, dismissed } (dismissed: the build whose bar was closed).
23
+ export function shouldShow(loaded, current, state = {}) {
24
+ const showing = !!state.showing
25
+ if (!current || typeof current.ui !== 'string' || !current.ui) return showing // no answer (or an older Mac): keep what's there
26
+ if (!loaded || !loaded.build) return false
27
+ // The board moved to another Mac (yours went to sleep): its files aren't an update of this page's.
28
+ if (loaded.machine && current.machine && loaded.machine !== current.machine) return showing
29
+ if (current.ui === loaded.build) return false
30
+ if (state.dismissed && state.dismissed === current.ui) return false
31
+ return true
32
+ }
33
+
34
+ // What a reload would lose, in words; empty when nothing. Drafts (the Live
35
+ // composer, replies) are carried over, so they aren't here.
36
+ export function reloadRisks({ attachments = 0, uploading = 0, unsavedSettings = false, typing = false } = {}) {
37
+ const out = []
38
+ if (uploading > 0) out.push(uploading === 1 ? 'A file is still uploading' : uploading + ' files are still uploading')
39
+ else if (attachments > 0) out.push(attachments === 1 ? 'The attached file won’t carry over' : 'The ' + attachments + ' attached files won’t carry over')
40
+ if (unsavedSettings) out.push('Unsaved settings will be lost')
41
+ if (typing) out.push('What you’re typing will be lost')
42
+ return out
43
+ }
44
+
45
+ // Drafts handed over a reload: written right before it, read once right after.
46
+ export function packCarry({ drafts = {}, focus = null, now = Date.now() } = {}) {
47
+ const d = {}
48
+ for (const [k, v] of Object.entries(drafts || {})) if (typeof v === 'string' && v.trim()) d[k] = v
49
+ return JSON.stringify({ at: now, drafts: d, focus: focus && d[focus.key] != null ? focus : null })
50
+ }
51
+ export function unpackCarry(raw, { now = Date.now(), maxAgeMs = CARRY_MAX_AGE_MS } = {}) {
52
+ try {
53
+ const j = JSON.parse(raw || '')
54
+ if (!j || typeof j.at !== 'number' || now - j.at > maxAgeMs || now < j.at - 5000) return null
55
+ const drafts = {}
56
+ for (const [k, v] of Object.entries(j.drafts || {})) if (typeof v === 'string') drafts[k] = v
57
+ const f = j.focus && typeof j.focus.key === 'string' ? { key: j.focus.key, a: Number(j.focus.a) || 0, b: Number(j.focus.b) || 0 } : null
58
+ return { drafts, focus: f }
59
+ } catch { return null }
60
+ }
61
+
62
+ // ------------------------------------------------------------ the bar (DOM)
63
+
64
+ // mountUpdateBar({ health, risks, carry, icon }) -> { check(h), shown() }
65
+ // health(): a fresh health answer (the board's refreshHealth); risks(): the
66
+ // reloadRisks input; carry(): { drafts, focus } to hand over.
67
+ export function mountUpdateBar({ health, risks = () => ({}), carry = () => ({}), icon = () => '', pollMs } = {}) {
68
+ const every = Number(pollMs || (typeof window !== 'undefined' && window.LEFTOFF_UPDATE_POLL_MS)) || POLL_MS
69
+ let loaded = pageBuild()
70
+ const st = { showing: false, dismissed: null, confirming: false, reloading: false }
71
+ let lastCheckAt = Date.now()
72
+ let el = null
73
+
74
+ function build() {
75
+ el = document.createElement('div')
76
+ el.className = 'updatebar'
77
+ el.id = 'updateBar'
78
+ el.setAttribute('role', 'status')
79
+ el.setAttribute('aria-live', 'polite')
80
+ el.hidden = true
81
+ el.innerHTML = '<span class="ub-icon" aria-hidden="true">' + icon('refresh-cw') + '</span><span class="ub-text">Leftoff updated</span>' +
82
+ '<button type="button" class="ub-go">Refresh</button><button type="button" class="ub-x" aria-label="Dismiss" title="Not now">' + icon('x') + '</button>'
83
+ document.body.appendChild(el)
84
+ // a click doesn't pull focus out of the box you're typing in
85
+ for (const b of el.querySelectorAll('button')) b.addEventListener('mousedown', (ev) => ev.preventDefault())
86
+ el.querySelector('.ub-go').addEventListener('click', onGo)
87
+ el.querySelector('.ub-x').addEventListener('click', onDismiss)
88
+ }
89
+ function paint() {
90
+ if (!el) build()
91
+ const was = !el.hidden
92
+ el.hidden = !st.showing
93
+ el.classList.toggle('confirm', st.confirming)
94
+ const text = el.querySelector('.ub-text'), go = el.querySelector('.ub-go')
95
+ if (st.confirming) { text.textContent = st.confirming; go.textContent = 'Refresh anyway' } else { text.textContent = 'Leftoff updated'; go.textContent = st.reloading ? 'Refreshing…' : 'Refresh' }
96
+ go.disabled = !!st.reloading
97
+ if (st.showing && !was) requestAnimationFrame(() => el && el.classList.add('in'))
98
+ if (!st.showing) el.classList.remove('in')
99
+ document.documentElement.classList.toggle('has-updatebar', st.showing)
100
+ }
101
+
102
+ // Every health answer the board gets comes through here (its own poll, the
103
+ // event stream reopening after a restart, the page coming back).
104
+ function check(h) {
105
+ lastCheckAt = Date.now()
106
+ if (h && !loaded && typeof h.ui === 'string' && h.ui) loaded = { build: h.ui, machine: h.machine || '' } // a page with no stamp: its first answer is its build
107
+ const next = shouldShow(loaded, h || null, st)
108
+ if (next !== st.showing) { st.showing = next; st.confirming = false; paint() }
109
+ }
110
+
111
+ function onDismiss() {
112
+ const h = lastHealth
113
+ st.dismissed = (h && h.ui) || st.dismissed || 'any'
114
+ st.showing = false; st.confirming = false
115
+ paint()
116
+ }
117
+ let lastHealth = null
118
+ function onHealth(h) { lastHealth = h; check(h) }
119
+
120
+ async function onGo() {
121
+ if (st.reloading) return
122
+ const words = reloadRisks(risks() || {})
123
+ if (words.length && !st.confirming) { st.confirming = words.join(' · '); paint(); return }
124
+ st.reloading = true; st.confirming = false; paint()
125
+ try { sessionStorage.setItem(CARRY_KEY, packCarry(carry() || {})) } catch {}
126
+ // A new service worker, if its file changed, before the page comes back.
127
+ try {
128
+ const reg = 'serviceWorker' in navigator && await navigator.serviceWorker.getRegistration()
129
+ if (reg) await Promise.race([reg.update(), new Promise((r) => setTimeout(r, 3000))])
130
+ } catch {}
131
+ location.reload()
132
+ }
133
+
134
+ // A health check at least every `every` ms while visible, and at once when
135
+ // the page comes back (the Home Screen app resumes, a bfcache restore).
136
+ const due = () => document.visibilityState !== 'hidden' && Date.now() - lastCheckAt >= every - 250
137
+ const tick = () => { if (due()) { lastCheckAt = Date.now(); Promise.resolve(health()).catch(() => {}) } }
138
+ setInterval(tick, Math.min(every, 15000))
139
+ const wake = (ev) => {
140
+ if (document.visibilityState === 'hidden') return
141
+ if (ev && ev.type === 'pageshow' && !ev.persisted) return
142
+ if (Date.now() - lastCheckAt < 3000) return
143
+ lastCheckAt = Date.now(); Promise.resolve(health()).catch(() => {})
144
+ }
145
+ document.addEventListener('visibilitychange', wake)
146
+ window.addEventListener('pageshow', wake)
147
+ window.addEventListener('online', wake)
148
+
149
+ return { check: onHealth, shown: () => st.showing, loaded: () => loaded }
150
+ }
151
+
152
+ // Right after a reload from the bar: the drafts it carried (once).
153
+ export function takeCarry(storage = typeof sessionStorage !== 'undefined' ? sessionStorage : null) {
154
+ if (!storage) return null
155
+ let raw = null
156
+ try { raw = storage.getItem(CARRY_KEY); if (raw != null) storage.removeItem(CARRY_KEY) } catch { return null }
157
+ return raw ? unpackCarry(raw) : null
158
+ }