@leftoff/cli 0.9.18-beta.7 → 0.9.18-beta.8

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 CHANGED
@@ -4,6 +4,7 @@ What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The boar
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ - **Photos picked on a phone attach again.** On iPhone, choosing a photo from the paperclip's picker closed the picker and nothing showed: coming back from the picker counts as a wake-up, which redraws the page, and iOS hands over the photo only after that, to the old picker the redraw had thrown away. The picker now lives outside the page, so a redraw can't lose what you picked, and a pick that still can't attach says why. Full screen also measures again as a phone's keyboard comes up, for keyboards (Raycast, Gboard, SwiftKey) that report late. A board reload is enough.
7
8
  - **Walkthroughs say whether a step follows or departs from a decision.** A decision chip on a step now reads ≠ Departs when the step goes against it, and the step always gets a Look closely note naming the decision; from there the decision viewer offers to link one of the session's own decisions as its replacement. A session's own decisions show in its walkthrough right away, marked ◌ Proposed, with Accept and Reject in the viewer. Needs `leftoff restart` and `leftoff upgrade --beta`.
8
9
  - **Weak tests caught without the model.** Walk me through it flags a test change that removes assertions and adds none, adds a skip or .only, asserts a value against itself, or mocks the module the test is for, and gives it a Look closely note even if the model put it under Safe to skip. Needs `leftoff restart`.
9
10
  - **Test plans from merged PRs.** A project's page, its decision log and the PRs page (pick merged PRs, then Test plan for these) make a short manual script for what merged lately: up to about 12 checks that fit 15, 30 or 60 minutes, each with where to go, the steps, what you should see and why it's on the list, ordered by risk. Risky callouts, weak tests and changed decisions always become checks or are listed under Not covered with the reason. Tick checks off, note what failed and propose a decision from it, then Copy as Markdown or Download .html. `leftoff testplan --project NAME` prints it. Needs `leftoff restart`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.7",
3
+ "version": "0.9.18-beta.8",
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": "One Silly App"
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.7' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.8' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.7",
3
+ "version": "0.9.18-beta.8",
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
@@ -2495,6 +2495,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2495
2495
  .composer .att-chips[hidden] { display: none; }
2496
2496
  .composer.att-drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); background: color-mix(in srgb, var(--accent) 7%, var(--card2)); }
2497
2497
  .att-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; overflow: hidden; clip: rect(0 0 0 0); }
2498
+ .att-picker { position: fixed; left: 0; bottom: 0; } /* 0.9.18: the paperclip's picker, outside the page so a redraw can't drop what you picked */
2498
2499
  .composer-attach { flex: none; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; background: transparent; display: grid; place-items: center; cursor: pointer; color: var(--muted); -webkit-tap-highlight-color: transparent; }
2499
2500
  .composer-clip { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; transition: background-color .15s, color .15s; }
2500
2501
  .composer-clip .ic { width: 19px; height: 19px; }
package/ui/app.js CHANGED
@@ -3459,6 +3459,15 @@ document.addEventListener('focusin', (e) => {
3459
3459
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3460
3460
  clearTimeout(lfKb.settle)
3461
3461
  if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3462
+ // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3463
+ // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3464
+ let i = 0
3465
+ const step = () => {
3466
+ if (!isTypingTarget(document.activeElement)) return
3467
+ fitFullViewport()
3468
+ if (++i < SETTLE_AFTER_BLUR_MS.length) lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[i] - SETTLE_AFTER_BLUR_MS[i - 1])
3469
+ }
3470
+ lfKb.settle = setTimeout(step, SETTLE_AFTER_BLUR_MS[0])
3462
3471
  })
3463
3472
  // A text box let go (a send closes the phone's keyboard, or a tap away): look again as the keyboard
3464
3473
  // goes down, whatever iOS reports or doesn't on the way.
@@ -3718,11 +3727,40 @@ function composerFor(el) {
3718
3727
  const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
3719
3728
  return box && !box.classList.contains('off') ? box : null
3720
3729
  }
3730
+ // The picker the paperclip opens lives outside the page (0.9.18). A phone's
3731
+ // photo picker covers the board for seconds; coming back is a wake-up (focus
3732
+ // after more than AWAY_MS away) that redraws the page, and iOS hands the
3733
+ // picked photo over only after that, to the <input> it opened from. A picker
3734
+ // inside the composer was gone by then (the redraw made a new one), so its
3735
+ // change event went nowhere and the photo never showed. This one is never
3736
+ // redrawn; the composer's own hidden input stays for drops and tests.
3737
+ let attPicker = null
3738
+ function attachPicker() {
3739
+ if (attPicker && attPicker.isConnected) return attPicker
3740
+ attPicker = document.createElement('input')
3741
+ attPicker.type = 'file'; attPicker.multiple = true; attPicker.tabIndex = -1
3742
+ attPicker.className = 'att-input att-picker'; attPicker.id = 'att-picker'
3743
+ attPicker.setAttribute('aria-hidden', 'true')
3744
+ document.body.appendChild(attPicker)
3745
+ return attPicker
3746
+ }
3747
+ function openPicker(sid) {
3748
+ if (!sid) return
3749
+ const pk = attachPicker()
3750
+ pk.dataset.sid = sid
3751
+ pk.value = ''
3752
+ pk.click() // still inside the tap: iOS opens a picker only from a user gesture
3753
+ }
3754
+ function takePicked(inp) {
3755
+ const files = Array.from(inp.files || [])
3756
+ if (!files.length) return
3757
+ inp.value = '' // the same file can be picked again
3758
+ try { addFiles(inp.dataset.sid, files) } catch (e) { toast('Couldn’t attach ' + (files.length === 1 ? files[0].name || 'that file' : files.length + ' files') + ': ' + (e.message || e), 6000) }
3759
+ }
3721
3760
  document.addEventListener('change', (ev) => {
3722
3761
  const inp = ev.target
3723
3762
  if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
3724
- addFiles(inp.dataset.sid, inp.files)
3725
- inp.value = '' // the same file can be picked again
3763
+ takePicked(inp)
3726
3764
  })
3727
3765
  document.addEventListener('paste', (ev) => {
3728
3766
  const t = ev.target
@@ -9481,7 +9519,7 @@ document.addEventListener('click', async (ev) => {
9481
9519
  if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
9482
9520
  if (action === 'lv-queued-now') return sendNowClick(btn)
9483
9521
  if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
9484
- if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
9522
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) openPicker(inp.dataset.sid); return }
9485
9523
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
9486
9524
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
9487
9525
  if (action === 'att-open') return openAttachment(btn)