@leftoff/cli 0.9.8 → 0.9.9-beta.1

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
@@ -2,8 +2,10 @@
2
2
 
3
3
  What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The board's **What's new** page and `leftoff changelog` both read this file.
4
4
 
5
- ## [0.9.8] - 2026-10-17
5
+ ## Unreleased
6
6
 
7
+ - Needs you: the Reply box is the same composer as Live (rounded box, round ↑ send, grows as you type; Enter sends on a Mac, Return is a new line on a phone), with the quick replies still under it. Your drafts carry over. The composer's 📎 and ↑, and the card's ✕, Go ↗ and other hints, show styled tips instead of browser tooltips.
8
+ - Hosted relay: its owner can see how many Macs connect each day (`/_admin/stats`, off until a `STATS_TOKEN` secret is set). Counts only: each account once a day as an opaque id, no IPs or content, kept 90 days.
7
9
  - **What it did**, on every session page (folded): the last tools it ran with their output, and **This folder's changes** (staged, unstaged and new files, tap one for its lines, drawn like GitHub: old and new line numbers, green and red rows, the changed words marked, a header that stays put while you scroll the file). Works for your other Mac's sessions too, read live; key and env files are never shown and secrets are blanked. In Live, tap a command under “Ran N commands” to see its output; an edit shows what it changed. A board answer too big for the relay now says so at once instead of hanging.
8
10
  - Board: the restore banner after a restart names the sessions (“**Fix login flow** (leftoff) was open …”, or the first two and “3 more”), so you can tell which before you tap Restore…; its ✕ buttons show styled tips.
9
11
  - Security: hosted signup is limited per network and relay-wide too, `+tags` count as one email, and a refused signup no longer says which name an email already has (except to that name's own Mac); the board serves only its own files, and `.secret/` is kept private on every start.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8",
3
+ "version": "0.9.9-beta.1",
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' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.1' // 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",
3
+ "version": "0.9.9-beta.1",
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
@@ -525,8 +525,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
525
525
  .need.k-permission .need-actions .btn, .need.k-permission .deny-form .btn { min-height: 44px; }
526
526
  .deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
527
527
  .deny-form[hidden] { display: none; }
528
- .deny-form input, .need .qother, .reply-text { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
529
- .deny-form input:focus, .need .qother:focus, .reply-text:focus { outline: 2px solid var(--focus); border-color: var(--accent); }
528
+ .deny-form input, .need .qother { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
529
+ .deny-form input:focus, .need .qother:focus { outline: 2px solid var(--focus); border-color: var(--accent); }
530
530
  .need-q { display: grid; gap: 6px; }
531
531
  .need-q .qtext { font-size: 13.5px; font-weight: 500; }
532
532
  .need-q .qhead { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-right: 8px; }
@@ -535,8 +535,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
535
535
  .btn.opt small { color: var(--muted); font-size: 11.5px; font-weight: 400; }
536
536
  .btn.opt[aria-pressed="true"] { border-color: var(--accent); background: var(--tint-accent); }
537
537
  .need-question { margin: 0; padding: 6px 10px; border-left: 3px solid var(--yellow); background: var(--subtle); color: var(--text2); font-size: 13.5px; white-space: pre-wrap; }
538
- .reply-row { display: flex; gap: 6px; align-items: flex-end; flex-wrap: wrap; }
539
- .reply-text { resize: vertical; min-height: 38px; }
538
+ /* 0.9.8 the Needs-you Reply box is the Live composer (.composer below) */
539
+ .need .reply-composer { margin-top: 8px; }
540
540
  .need-badge { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; padding: 6px 9px; border-radius: 8px; background: var(--tint-yellow); min-width: 0; }
541
541
  .need-badge.blocking { background: var(--tint-red); }
542
542
  .need-badge .nb-k { font-weight: 700; color: var(--yellow); flex: none; }
@@ -1365,7 +1365,7 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
1365
1365
  .empty.login .codeform input { font-size: 22px !important; }
1366
1366
  .cmdk-field input, .pairbox input { font-size: 17px !important; }
1367
1367
  .addform input { padding: 5px 10px; }
1368
- .deny-form input, .need .qother, .reply-text { padding: 6px 9px; }
1368
+ .deny-form input, .need .qother { padding: 6px 9px; }
1369
1369
  .card .title input { padding: 2px 6px; }
1370
1370
  .standup-out textarea { line-height: 1.4; }
1371
1371
  }
@@ -2163,6 +2163,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2163
2163
  .composer-attach:focus { outline: none; }
2164
2164
  .composer-attach:focus-visible .composer-clip { outline: 2px solid var(--accent); outline-offset: 2px; }
2165
2165
  .composer-attach:disabled { cursor: default; opacity: .5; }
2166
+ /* 0.9.8 styled tooltips (data-tip, never title=) for the composer's 📎 and ↑
2167
+ (Live and the Needs-you Reply box), the Needs-you card's controls and Go ↗.
2168
+ Hover with a mouse, or keyboard focus; display:none otherwise, so a tip
2169
+ never widens a phone page. aria-label carries the words for a screen reader. */
2170
+ :where(.composer-attach[data-tip], .composer-send[data-tip], .need [data-tip], .btn.go[data-tip]) { position: relative; }
2171
+ .composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; max-width: min(260px, 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; font-style: normal; text-align: left; white-space: normal; letter-spacing: normal; pointer-events: none; z-index: 20; }
2172
+ .composer-attach[data-tip]::after, .composer-send[data-tip]::after { top: auto; bottom: calc(100% + 4px); }
2173
+ .composer-attach[data-tip]::after { right: auto; left: 0; }
2174
+ @media (hover: hover) { .composer-attach[data-tip]:hover::after, .composer-send[data-tip]:hover::after, .need [data-tip]:hover::after, .btn.go[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
2175
+ .composer-attach[data-tip]:focus-visible::after, .composer-send[data-tip]:focus-visible::after, .need [data-tip]:focus-visible::after, .btn.go[data-tip]:focus-visible::after { display: block; animation: tip-in .12s ease-out both; }
2176
+ @keyframes tip-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
2177
+ @media (prefers-reduced-motion: reduce) { .composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { animation: none !important; } }
2166
2178
  .att-chip { position: relative; display: flex; align-items: center; gap: 8px; max-width: 100%; min-width: 0; width: auto; flex: 0 1 auto; height: 52px; padding: 4px 0 4px 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); overflow: hidden; cursor: default; }
2167
2179
  [data-theme="light"] .att-chip { background: #f6f7f9; }
2168
2180
  .att-chip.failed { border-color: color-mix(in srgb, var(--red) 60%, var(--line)); }
package/ui/app.js CHANGED
@@ -15,7 +15,7 @@ import { canStartPull, pullStep, pullRelease } from './pull.js'
15
15
  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
- import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
18
+ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
19
  import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
20
20
  import { wireTips } from './tips.js'
21
21
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
@@ -426,15 +426,15 @@ function goButton(s, { page = false } = {}) {
426
426
  if (!j.peer) return ''
427
427
  // 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
428
428
  const label = 'Go ↗', on = 'Go on ' + s.machine
429
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
430
- if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
429
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
430
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
431
431
  const st = goState.get(s.id)
432
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
432
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" data-tip="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
433
433
  }
434
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
435
- if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
434
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled data-tip="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
435
+ if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
436
436
  const st = goState.get(s.id)
437
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" title="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
437
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" data-tip="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
438
438
  }
439
439
 
440
440
  // The short reason a cross-machine button is grey (the full one is its tooltip).
@@ -1264,7 +1264,7 @@ function paintNeedsCount(n) {
1264
1264
 
1265
1265
  const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
1266
1266
  function needOpts(extra = {}) {
1267
- return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
1267
+ return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', composerKeys: isTouchUi(window.matchMedia) ? 'Meta+Enter Control+Enter' : 'Enter Meta+Enter Control+Enter', ...extra }
1268
1268
  }
1269
1269
 
1270
1270
  // ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
@@ -1393,7 +1393,7 @@ function paintSession(s, data) {
1393
1393
  $app.innerHTML = sessionPageHtml(s, data)
1394
1394
  liveAfterRender(s.id)
1395
1395
  if (kf) {
1396
- const nt = $app.querySelector('.composer-input')
1396
+ const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
1397
1397
  if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
1398
1398
  } else if (kb && !$app.contains(document.activeElement)) {
1399
1399
  const hits = $app.querySelectorAll(kb)
@@ -1594,20 +1594,12 @@ function liveBoxHtml(s, v) {
1594
1594
  // grows, and a round ↑ in its corner (dim until there's something to send).
1595
1595
  // No interrupt reaches Claude Code from the board, so while it works the
1596
1596
  // button still sends: the message waits for its next turn, as before.
1597
- const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
1598
- const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
1599
1597
  const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
1600
1598
  function composerHtml(s, draft, may) {
1601
1599
  const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1602
- const atts = attachList(s.id)
1603
- const off = !may || (isBlank(draft) && !atts.length)
1604
- return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1605
- '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1606
- '<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1607
- // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1608
- '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1609
- '<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
1610
- '<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
1600
+ return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
1601
+ placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
1602
+ attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
1611
1603
  }
1612
1604
 
1613
1605
  // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
@@ -1895,6 +1887,7 @@ function syncComposer(ta) {
1895
1887
  }
1896
1888
  document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1897
1889
  // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1890
+ // The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
1898
1891
  document.addEventListener('keydown', (ev) => {
1899
1892
  const ta = ev.target
1900
1893
  if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
@@ -1903,8 +1896,8 @@ document.addEventListener('keydown', (ev) => {
1903
1896
  ev.preventDefault()
1904
1897
  const cb = ta.closest('.composer')
1905
1898
  if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1906
- const box = ta.closest('.live-box')
1907
- const btn = box && box.querySelector('[data-submit-for="live"]')
1899
+ const box = ta.closest('.live-box') || ta.closest('.need')
1900
+ const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
1908
1901
  if (!btn || btn.disabled) return
1909
1902
  const last = Number(box.dataset.kbdAt || 0)
1910
1903
  if (Date.now() - last < 1500) return
@@ -4153,6 +4146,8 @@ async function render() {
4153
4146
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
4154
4147
  else await viewBoard()
4155
4148
  tabsAfterRender()
4149
+ // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4150
+ for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4156
4151
  } catch (e) {
4157
4152
  if (e.code === 'login_required') return showLogin()
4158
4153
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -5667,9 +5662,11 @@ async function replyClick(btn) {
5667
5662
  const need = btn.closest('.need')
5668
5663
  const ta = need && need.querySelector('.reply-text')
5669
5664
  const text = ta ? ta.value.trim() : ''
5670
- if (!text) { toast('Type a reply first'); return }
5665
+ if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
5671
5666
  if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
5672
- btn.disabled = true; btn.textContent = 'Sending…'
5667
+ if (btn.classList.contains('sending')) return
5668
+ // the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
5669
+ btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
5673
5670
  try {
5674
5671
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
5675
5672
  delete state.drafts['reply:' + need.dataset.id]
package/ui/composer.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // something to send. On a Mac (a fine pointer) Enter sends and Shift+Enter is a
5
5
  // new line; on a phone or tablet Return is a new line and the button sends.
6
6
  // ⌘↵ / Ctrl+↵ sends everywhere. Pure functions here (shared with the unit
7
- // tests); app.js wires them to the page.
7
+ // tests); app.js wires them to the page. composerBoxHtml() is the box's markup,
8
+ // shared by Live and the Needs-you Reply card (ui/needs.js).
8
9
 
9
10
  export const COMPOSER_MAX_LINES = 8
10
11
 
@@ -44,3 +45,40 @@ export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, ma
44
45
  const want = Math.ceil(Number(scrollHeight) || 0)
45
46
  return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
46
47
  }
48
+
49
+ // The box's markup (Live and the Needs-you Reply card: one look, one
50
+ // behaviour). Pure: the caller hands in what it knows.
51
+ // submit: the data-submit / data-submit-for pair ('live' | 'reply')
52
+ // sendAction: the ↑'s data-action ('live-send' | 'reply-send')
53
+ // draftKey, draft: the textarea's data-draft key and its text
54
+ // may: answering from this device is allowed (false: everything disabled)
55
+ // busy: a send is in flight (the ↑ stays disabled)
56
+ // placeholder, label: the textarea's placeholder and aria-label
57
+ // inputClass: extra class(es) on the textarea (the app's own hooks)
58
+ // sid: the session; on the box as data-sid only when attach is on (the
59
+ // attachment code finds a session's composer by it)
60
+ // attach: show the 📎 and the hidden file input; atts: its chips ([]),
61
+ // chipHtml: chip -> markup
62
+ // keys: the textarea's aria-keyshortcuts
63
+ // Tooltips are the board's styled data-tip (never title=); aria-label stays.
64
+ export const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
65
+ export const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
66
+ const escA = (v) => String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
67
+ export function composerBoxHtml(o = {}) {
68
+ const may = o.may !== false
69
+ const atts = o.attach && Array.isArray(o.atts) ? o.atts : []
70
+ const sid = o.sid == null ? '' : String(o.sid)
71
+ const off = !may || o.busy || (isBlank(o.draft) && !atts.length)
72
+ const submit = escA(o.submit || 'live')
73
+ const label = o.label || o.placeholder || 'Message'
74
+ const dis = may ? '' : ' disabled'
75
+ return '<div class="composer' + (o.boxClass ? ' ' + escA(o.boxClass) : '') + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '"' + (o.attach && sid ? ' data-sid="' + escA(sid) + '"' : '') + '>' +
76
+ (o.attach ? '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(o.chipHtml || (() => '')).join('') + '</div>' : '') +
77
+ '<textarea class="composer-input' + (o.inputClass ? ' ' + escA(o.inputClass) : '') + '" rows="1" maxlength="' + (Number(o.maxlength) || 4000) + '" placeholder="' + escA(o.placeholder || '') + '" aria-label="' + escA(label) + '" autocomplete="off" data-draft="' + escA(o.draftKey || '') + '" data-submit="' + submit + '"' + (o.keys ? ' aria-keyshortcuts="' + escA(o.keys) + '"' : '') + dis + '>' + escA(o.draft || '') + '</textarea>' +
78
+ (o.attach
79
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
80
+ ? '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + escA(sid) + '"' + dis + '>' +
81
+ '<button type="button" class="composer-attach" data-action="attach" data-id="' + escA(sid) + '" aria-label="Attach" data-tip="Attach photos or files"' + dis + '><span class="composer-clip">' + CLIP_IC + '</span></button>'
82
+ : '') +
83
+ '<button type="button" class="composer-send" data-action="' + escA(o.sendAction || 'live-send') + '" data-submit-for="' + submit + '" data-id="' + escA(sid) + '" aria-label="Send" data-tip="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
84
+ }
package/ui/needs.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { mdInline } from './changelog-md.js'
11
11
  import { tipHtml, tipId } from './tips.js'
12
+ import { composerBoxHtml } from './composer.js'
12
13
 
13
14
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
14
15
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -41,7 +42,7 @@ export function needsLine(n) {
41
42
  // Dismiss (0.6.5): hides this one need on every board; a new question shows again.
42
43
  export function dismissButton(n) {
43
44
  if (!n || !n.key) return ''
44
- return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" title="Dismiss: hide this one (a new question shows again)">✕</button>'
45
+ return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" data-tip="Dismiss: hide this one (a new question shows again)">✕</button>'
45
46
  }
46
47
 
47
48
  const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
@@ -100,9 +101,9 @@ export function needPanel(n, opts = {}) {
100
101
  let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
101
102
  h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
102
103
  (opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
103
- (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
104
+ (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" data-tip="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
104
105
  '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
105
- (n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
106
+ (n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
106
107
  h += needContextHtml(n, opts) + prCardHtml(n.pr)
107
108
  if (n.kind === 'permission' && n.ask) {
108
109
  const d = n.ask.describe || {}
@@ -112,7 +113,7 @@ export function needPanel(n, opts = {}) {
112
113
  h += '<div class="need-actions">' +
113
114
  '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
114
115
  alwaysButton(n.ask, dis, { session: s }) +
115
- (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
116
+ (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' data-tip="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
116
117
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
117
118
  '<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
118
119
  '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
@@ -123,14 +124,14 @@ export function needPanel(n, opts = {}) {
123
124
  h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
124
125
  '<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
125
126
  if (q.options.length) {
126
- h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + (o.description ? ' title="' + esc(o.description) + '"' : '') + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
127
+ h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
127
128
  }
128
129
  h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
129
130
  h += '</div>'
130
131
  })
131
132
  h += quickRepliesHtml(n, dis)
132
133
  h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
133
- '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' title="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
134
+ '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' data-tip="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
134
135
  } else if (n.kind === 'terminal') {
135
136
  h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
136
137
  '<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
@@ -138,8 +139,12 @@ export function needPanel(n, opts = {}) {
138
139
  h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
139
140
  if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
140
141
  if (n.canReply) {
141
- h += '<div class="reply-row"><textarea class="reply-text" rows="2" maxlength="4000" placeholder="Reply to Claude…" data-draft="reply:' + esc(s.id) + '" data-submit="reply" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>' + esc(drafts['reply:' + s.id] || '') + '</textarea>' +
142
- '<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div>' + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
142
+ // 0.9.8: the same composer as Live (ui/composer.js): rounded box, round ↑,
143
+ // Enter sends on a Mac (⌘↵ everywhere). A reply is text only (Claude reads
144
+ // it as its next prompt), so there is no 📎 here.
145
+ h += composerBoxHtml({ submit: 'reply', sendAction: 'reply-send', sid: s.id, draftKey: 'reply:' + s.id, draft: drafts['reply:' + s.id] || '',
146
+ may, busy: !!busy, placeholder: 'Reply to Claude…', label: 'Reply to Claude', boxClass: 'reply-composer', inputClass: 'reply-text',
147
+ attach: false, keys: opts.composerKeys || KBD_SUBMIT }) + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
143
148
  } else {
144
149
  h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
145
150
  }
@@ -191,8 +196,8 @@ export function agentChipHtml(a = {}, { title } = {}) {
191
196
  const inner = esc(a.icon || '◆') + ' ' + esc(who)
192
197
  const t = esc(title || 'external agent · read-only')
193
198
  const url = httpsOnly(a.url)
194
- return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + t + '">' + inner + '</a>'
195
- : '<span class="chip machine agent" title="' + t + '">' + inner + '</span>'
199
+ return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + t + '">' + inner + '</a>'
200
+ : '<span class="chip machine agent" data-tip="' + t + '">' + inner + '</span>'
196
201
  }
197
202
  // "From Tab · reply by texting Tab", or "From Tab · Read-only here".
198
203
  export function agentReplyNote(a = {}) {
@@ -408,7 +413,7 @@ export function prCardHtml(pr) {
408
413
  const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
409
414
  (ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
410
415
  '<span class="pr-title">' + esc(pr.title || '') + '</span>'
411
- return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
416
+ return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
412
417
  : '<div class="need-pr">' + inner + '</div>'
413
418
  }
414
419