@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 +3 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +17 -5
- package/ui/app.js +21 -24
- package/ui/composer.js +39 -1
- package/ui/needs.js +16 -11
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
|
-
##
|
|
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.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.
|
|
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.
|
|
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
|
|
529
|
-
.deny-form input:focus, .need .qother:focus
|
|
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
|
-
.
|
|
539
|
-
.reply-
|
|
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
|
|
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) + '"
|
|
430
|
-
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
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) + '"
|
|
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
|
|
435
|
-
if (!j.supported) return '<button type="button" class="btn go" disabled
|
|
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 ↗"
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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"
|
|
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"
|
|
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"
|
|
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 + '
|
|
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 +
|
|
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 + '
|
|
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
|
-
|
|
142
|
-
|
|
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"
|
|
195
|
-
: '<span class="chip machine agent"
|
|
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"
|
|
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
|
|