@leftoff/cli 0.9.8 → 0.9.9-beta.10

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/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
 
@@ -33,6 +34,18 @@ export function isTouchUi(matchMedia) {
33
34
  } catch { return false }
34
35
  }
35
36
 
37
+ // 0.9.9 After a send that went through, a phone's keyboard closes: the text
38
+ // box lets go of focus. On a Mac (a fine pointer) focus stays, so you can keep
39
+ // typing. A send that failed keeps its text and its focus everywhere.
40
+ export function keepFocusAfterSend({ ok = false, touch = false } = {}) { return !ok || !touch }
41
+ // Let go of the focused text box (the on-screen keyboard closes); true if one had it.
42
+ export function letGoOfKeyboard(doc) {
43
+ const a = doc && doc.activeElement
44
+ if (!a || a === doc.body || !/^(TEXTAREA|INPUT)$/.test(String(a.tagName || '').toUpperCase()) || typeof a.blur !== 'function') return false
45
+ a.blur()
46
+ return true
47
+ }
48
+
36
49
  // The height the box should be for its content: scrollHeight is the
37
50
  // textarea's at height:auto (padding included), lineHeight and padY in px.
38
51
  // -> { height, scroll } where scroll says the content is taller than the cap.
@@ -44,3 +57,40 @@ export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, ma
44
57
  const want = Math.ceil(Number(scrollHeight) || 0)
45
58
  return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
46
59
  }
60
+
61
+ // The box's markup (Live and the Needs-you Reply card: one look, one
62
+ // behaviour). Pure: the caller hands in what it knows.
63
+ // submit: the data-submit / data-submit-for pair ('live' | 'reply')
64
+ // sendAction: the ↑'s data-action ('live-send' | 'reply-send')
65
+ // draftKey, draft: the textarea's data-draft key and its text
66
+ // may: answering from this device is allowed (false: everything disabled)
67
+ // busy: a send is in flight (the ↑ stays disabled)
68
+ // placeholder, label: the textarea's placeholder and aria-label
69
+ // inputClass: extra class(es) on the textarea (the app's own hooks)
70
+ // sid: the session; on the box as data-sid only when attach is on (the
71
+ // attachment code finds a session's composer by it)
72
+ // attach: show the 📎 and the hidden file input; atts: its chips ([]),
73
+ // chipHtml: chip -> markup
74
+ // keys: the textarea's aria-keyshortcuts
75
+ // Tooltips are the board's styled data-tip (never title=); aria-label stays.
76
+ 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>'
77
+ 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>'
78
+ const escA = (v) => String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
79
+ export function composerBoxHtml(o = {}) {
80
+ const may = o.may !== false
81
+ const atts = o.attach && Array.isArray(o.atts) ? o.atts : []
82
+ const sid = o.sid == null ? '' : String(o.sid)
83
+ const off = !may || o.busy || (isBlank(o.draft) && !atts.length)
84
+ const submit = escA(o.submit || 'live')
85
+ const label = o.label || o.placeholder || 'Message'
86
+ const dis = may ? '' : ' disabled'
87
+ return '<div class="composer' + (o.boxClass ? ' ' + escA(o.boxClass) : '') + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '"' + (o.attach && sid ? ' data-sid="' + escA(sid) + '"' : '') + '>' +
88
+ (o.attach ? '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(o.chipHtml || (() => '')).join('') + '</div>' : '') +
89
+ '<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>' +
90
+ (o.attach
91
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
92
+ ? '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + escA(sid) + '"' + dis + '>' +
93
+ '<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>'
94
+ : '') +
95
+ '<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>'
96
+ }
package/ui/facts.js ADDED
@@ -0,0 +1,34 @@
1
+ // 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
2
+ // Pure (the unit tests read it). A card whose value is an id or a path is a
3
+ // button: tap or click copies the whole value (the toast says it), the whole
4
+ // value is its data-tip, and it is a 44px target. A long path shows its start
5
+ // and its end, the part that tells worktrees apart.
6
+ import { icon } from './icons.js'
7
+ import { midClip } from './live-view.js'
8
+
9
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
10
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
11
+
12
+ // factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
13
+ // copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
14
+ // shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
15
+ // A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
16
+ export function clipPath(p, n = 34) {
17
+ const t = String(p == null ? '' : p)
18
+ if (t.length <= n) return t
19
+ const i = t.replace(/\/+$/, '').lastIndexOf('/')
20
+ const last = i >= 0 ? t.slice(i + 1) : ''
21
+ if (!last || last.length > n - 8) return midClip(t, n)
22
+ return t.slice(0, n - last.length - 2) + '…/' + last
23
+ }
24
+
25
+ export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
26
+ const value = String(v == null ? '' : v)
27
+ const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
28
+ if (copy == null || copy === '') {
29
+ return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
30
+ }
31
+ const full = String(copy)
32
+ return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
33
+ '<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
34
+ }
package/ui/icons.js CHANGED
@@ -17,6 +17,7 @@ export const ICONS = {
17
17
  "qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
18
18
  "refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
19
19
  "check": "<path d=\"M20 6 9 17l-5-5\"/>",
20
+ "lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
20
21
  "folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
21
22
  "bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
22
23
  "x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
@@ -46,6 +47,7 @@ export const ICONS = {
46
47
  "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
47
48
  "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
48
49
  "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
50
+ "square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
49
51
  "message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
50
52
  }
51
53
 
package/ui/index.html CHANGED
@@ -36,6 +36,7 @@
36
36
  </nav>
37
37
  <div class="topright">
38
38
  <button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><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"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
39
+ <a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><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="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
39
40
  <button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
40
41
  <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><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="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
41
42
  <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
package/ui/live-status.js CHANGED
@@ -16,6 +16,10 @@
16
16
  // folded into that turn (folded: the id of the message that owns the turn):
17
17
  // it has no Working bubble of its own, and when the turn ends it is
18
18
  // "Answered with the reply above".
19
+ // 0.9.9: until Claude takes it in (at its next step, or when the turn ends)
20
+ // such a message is Queued (queued: true; cancellable: Leftoff can still take
21
+ // it back; sendNow: Send now can make it run next). Anything Claude does
22
+ // with it ends that.
19
23
 
20
24
  export const LIVE_QUIET_MS = 3 * 60 * 1000
21
25
  export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
@@ -27,11 +31,15 @@ export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool:
27
31
 
28
32
  // One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
29
33
  // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
34
+ const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
30
35
  export function stepStatus(st, ev = {}) {
31
36
  const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
32
37
  const at = ev.at || s.last
33
- if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
38
+ if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable; delete s.sendNow } return s }
39
+ if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable; delete s.sendNow }
34
40
  switch (ev.type) {
41
+ case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable; if (ev.cancellable && ev.sendNow) s.sendNow = true; else delete s.sendNow } else return s; break
42
+ case 'unqueued': break
35
43
  case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
36
44
  case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
37
45
  case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
@@ -57,12 +65,16 @@ export function stepStatus(st, ev = {}) {
57
65
  return s
58
66
  }
59
67
 
68
+ // Waiting behind Claude's turn, not taken in yet.
69
+ export function isQueued(st) { return !!(st && st.queued && !st.folded && (st.phase === 'sent' || st.phase === 'received')) }
70
+
60
71
  // The line under the message: { label, steps, spin, tone, elapsedMs }
61
72
  export function statusView(st, now = Date.now()) {
62
73
  if (!st) return null
63
74
  const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
64
75
  const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
65
76
  let label
77
+ if (isQueued(st)) return { label: 'Queued', steps: [{ name: 'Sent', state: 'done' }, { name: 'Queued', state: 'now' }], spin: false, tone: 'queued', quiet: false, elapsedMs, final: false, queued: true }
66
78
  if (st.phase === 'sent') label = 'Sent, waiting for the session'
67
79
  else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
68
80
  else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
package/ui/live-view.js CHANGED
@@ -3,12 +3,29 @@
3
3
  // thread survive the board's re-renders: the same list element stays on the
4
4
  // page, messages are diffed in, and it sticks to the bottom only when you are
5
5
  // already there.
6
- import { statusView, elapsedText, ranWithoutAsking } from './live-status.js'
6
+ import { statusView, elapsedText, ranWithoutAsking, isQueued } from './live-status.js'
7
7
  import { parseAttachedLines } from './attach.js'
8
8
 
9
9
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
10
10
  export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
11
11
 
12
+ // 0.9.9: which Live box a session's page shows, and so whether it fetches the
13
+ // thread. A session Leftoff started (headless) always has one, whatever its
14
+ // capture says ("hooks": its card came from the daemon before Claude's first
15
+ // hook): its first message and the reply are in it, while it runs and after
16
+ // it stops. Another hooks-only session has no Live at all; any other one has
17
+ // a thread while it is live-linked and the how-to otherwise.
18
+ // -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
19
+ export function liveBoxKind(s) {
20
+ if (!s || s.agent || s.codingAgent === 'codex') return 'none'
21
+ if (s.capture === 'hooks' && !s.headless) return 'none'
22
+ if (s.liveLink && s.liveLink.on) return 'thread'
23
+ if (s.headless) return 'readonly'
24
+ if (s.endedAt || s.live === 'closed') return 'none'
25
+ return 'how'
26
+ }
27
+ export const liveThreadWanted = (s) => { const k = liveBoxKind(s); return k === 'thread' || k === 'readonly' }
28
+
12
29
  // The name a session goes by. A live session is never "Untitled": until it has
13
30
  // a title (its first prompt, Claude Code's /rename, a generated one) it goes by
14
31
  // its repo and branch.
@@ -201,14 +218,17 @@ export function messageAttachments(m) {
201
218
  if (!/Attached: /.test(m.text || '')) return { text: m.text || '', attachments: [] }
202
219
  return parseAttachedLines(m.text)
203
220
  }
204
- export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, attHtml = null }) {
221
+ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, attHtml = null, may = true }) {
205
222
  const who = senderLabel(m)
206
223
  const title = m.from === 'you' ? senderTitle(m) : ''
207
224
  let body
208
225
  if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
209
226
  else if (m.from === 'you') {
210
227
  const a = messageAttachments(m)
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) : '')
228
+ // 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
229
+ const fold = isQueued(m.status) && queuedLong(a.text)
230
+ const open = fold ? ' lv-q-long" role="button" tabindex="0" aria-expanded="false" aria-label="Show the whole message" data-action="lv-queued-more' : ''
231
+ body = (a.text || !a.attachments.length ? '<div class="lv-text lv-md' + open + '">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
212
232
  } else if (m.from === 'terminal') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
213
233
  else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
214
234
  let foot = ''
@@ -220,13 +240,19 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
220
240
  if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
221
241
  const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
222
242
  if (ran.length) foot += '<div class="lv-note">Ran without asking: ' + esc(ran.join(', ')) + ' <span class="faint">(read-only or allowed in your Claude Code settings, so no Allow/Deny)</span></div>'
243
+ } else if (m.status && m.from === 'you' && isQueued(m.status)) {
244
+ foot = queuedHtml(m, { esc, may })
223
245
  } else if (m.status) {
224
246
  foot = deliveryHtml(m.status, { esc, error: m.errorText })
225
247
  } else if (m.state) {
226
248
  foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
227
249
  }
228
- const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
229
- return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
250
+ const queued = m.from === 'you' && isQueued(m.status)
251
+ const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '') + (queued ? '|q' + (may ? 1 : 0) : '')
252
+ // 0.9.9: a Queued one is compact: no "You · from this Mac 10:04 PM" line
253
+ // (it comes back once Claude takes the message in)
254
+ const head = queued ? '' : '<div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>'
255
+ return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
230
256
  }
231
257
 
232
258
  const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
@@ -242,6 +268,26 @@ export function deliveryHtml(st, { esc, error = null }) {
242
268
  return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
243
269
  }
244
270
 
271
+ // 0.9.9 Under a message still waiting behind Claude's turn, one small row:
272
+ // "◷ Queued · Send now · Edit · Cancel". Send now where Claude Code can make it
273
+ // run next (a session Leftoff runs, a CLI with Send now); Edit and Cancel where
274
+ // Leftoff can truly take it back; and only if you may send from this device.
275
+ // Otherwise the plain fact. Small text buttons, each with a 44px hit area
276
+ // (app.css). No tooltip.
277
+ export function queuedHtml(m, { esc, may = true }) {
278
+ const st = m.status || {}
279
+ const id = esc(m.id || '')
280
+ const dot = '<span class="lv-q-dot" aria-hidden="true">·</span>'
281
+ const btn = (action, label, aria) => dot + '<button type="button" class="lv-q-btn" data-action="' + action + '" data-msg="' + id + '" aria-label="' + aria + '">' + label + '</button>'
282
+ const acts = st.cancellable && may
283
+ ? (st.sendNow ? btn('lv-queued-now', 'Send now', 'Send now: Claude stops its current step') : '') +
284
+ btn('lv-queued-edit', 'Edit', 'Edit this queued message') + btn('lv-queued-cancel', 'Cancel', 'Cancel this queued message')
285
+ : dot + '<span class="lv-q-hint">Claude reads it after its current step</span>'
286
+ return '<div class="lv-q" role="status"><span class="lv-q-tag">Queued</span>' + acts + '</div>'
287
+ }
288
+ // A queued message longer than about two lines folds (tap to open in place).
289
+ export function queuedLong(text) { const t = String(text || '').trim(); return t.length > 80 || t.includes('\n') }
290
+
245
291
  // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
246
292
  export function midClip(s, n = 56) {
247
293
  const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
@@ -295,19 +341,125 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
295
341
  }
296
342
 
297
343
  // The whole thread: each message you sent, then Claude's side of that turn.
344
+ // opts.inserts (0.9.9): other rows ({ at, html }: the permission asks) go in
345
+ // where they happened, before the first message after them; with earlier
346
+ // messages not shown yet, one from before the first shown waits for "Show earlier".
347
+ // A turn's asks sit before that turn's reply, so the newest reply is the last
348
+ // thing in the thread: the reply entry is made when Claude's first words of
349
+ // the turn land and keeps that time, so its own time can't place them; the
350
+ // turn's span (up to the next message, or the turn's end) does.
351
+ // Answered asks next to each other fold into one row (askGroupHtml);
352
+ // opts.openGroups: the group keys opened on this page.
298
353
  export function threadHtml(msgs, opts) {
299
354
  const list = msgs || []
300
355
  const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
301
356
  const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
357
+ const t = (x) => { const v = typeof x === 'number' ? x : Date.parse(x || ''); return Number.isFinite(v) ? v : null }
358
+ const first = list.length ? t(list[0].at) : null
359
+ const ins = ((opts && opts.inserts) || []).filter((r) => r && r.html)
360
+ .map((r, i) => ({ ...r, t: t(r.at), i }))
361
+ .filter((r) => !(opts.earlier > 0 && first != null && r.t != null && r.t < first))
362
+ .sort((a, b) => ((a.t == null ? Infinity : a.t) - (b.t == null ? Infinity : b.t)) || a.i - b.i)
363
+ let k = 0
364
+ const take = (at) => { const out = []; while (k < ins.length && ins[k].t != null && at != null && ins[k].t < at) out.push(ins[k++]); return out }
365
+ const upTo = (at) => slotHtml(take(at), opts)
366
+ // 0.9.9: messages Queued behind Claude's turn wait at the bottom, in the order
367
+ // sent, with no Working bubble; once Claude takes one in it goes back to its place
368
+ const waiting = list.filter((m) => m.from === 'you' && isQueued(m.status) && !answered.has(m.id))
369
+ const rest = waiting.length ? list.filter((m) => !waiting.includes(m)) : list
302
370
  let html = ''
303
- for (const m of list) {
304
- if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); continue }
371
+ rest.forEach((m, i) => {
372
+ const next = i + 1 < rest.length ? t(rest[i + 1].at) : null
373
+ if (m.from === 'claude') {
374
+ const you = m.replyTo ? byId.get(m.replyTo) : null
375
+ html += upTo(Math.max(t(m.at) == null ? -Infinity : t(m.at), replyBefore(m, you, next, t)))
376
+ html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null })
377
+ return
378
+ }
379
+ html += upTo(t(m.at))
305
380
  html += entryHtml(m, opts)
306
- if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
381
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
382
+ // the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
383
+ html += upTo(next == null ? Infinity : next)
384
+ html += pendingClaudeHtml(m, opts)
385
+ }
386
+ })
387
+ html += slotHtml(ins.slice(k), opts)
388
+ for (const m of waiting) html += entryHtml(m, opts)
389
+ return html
390
+ }
391
+
392
+ // Rows from before this time go above Claude's reply m. A reply to a message
393
+ // (yours, or a prompt typed in the terminal) closes its turn: everything up to
394
+ // the next message is that turn's. The last one: up to the turn's end (the
395
+ // board message's endedAt; a transcript reply's own time is when its last
396
+ // words landed), or everything while the turn still runs. A reply with no
397
+ // message of its own (Claude's reply tool) stays where its time puts it.
398
+ const TURN_END_SLACK_MS = 1000
399
+ export function replyBefore(m, you, next, t = (x) => { const v = typeof x === 'number' ? x : Date.parse(x || ''); return Number.isFinite(v) ? v : null }) {
400
+ if (!m || !m.replyTo) return -Infinity
401
+ if (next != null) return next
402
+ const st = you && you.status
403
+ if (st) return st.endedAt ? t(st.endedAt) + TURN_END_SLACK_MS : (m.done === false || !FINAL_PHASES.has(st.phase) ? Infinity : t(m.at) + TURN_END_SLACK_MS)
404
+ const end = t(m.at)
405
+ return end == null ? -Infinity : end + TURN_END_SLACK_MS
406
+ }
407
+
408
+ // One place in the thread between two messages: its rows in order, answered
409
+ // asks next to each other folded into one row; a waiting one is never folded.
410
+ export function slotHtml(rows, opts = {}) {
411
+ let html = ''
412
+ let run = []
413
+ const flush = () => { if (run.length) html += askGroupHtml(run, opts); run = [] }
414
+ for (const r of rows || []) {
415
+ if (r.fold) { run.push(r); continue }
416
+ flush()
417
+ html += r.html
307
418
  }
419
+ flush()
308
420
  return html
309
421
  }
310
422
 
423
+ // "▸ 7 asks · 6 allowed · 1 always allowed": answered asks folded into one
424
+ // compact row; tapping it opens the rows in place (no popover), tapping again
425
+ // folds them. Closed by default, and on every fresh page.
426
+ const FOLD_ORDER = ['Allowed', 'Allowed for this session', 'Always allowed here', 'Auto-approved', 'Answered', 'Denied', 'Skipped']
427
+ const FOLD_WORD = { Allowed: 'allowed', 'Allowed for this session': 'allowed for this session', 'Always allowed here': 'always allowed', 'Auto-approved': 'auto-approved', Answered: 'answered', Denied: 'denied', Skipped: 'skipped' }
428
+ const FOLD_NO = new Set(['Denied', 'Skipped'])
429
+ export function askGroupKey(rows) { return 'askg:' + ((rows && rows[0] && rows[0].key) || '') }
430
+ export function askGroupCounts(rows) {
431
+ const by = new Map()
432
+ for (const r of rows || []) { const st = (r.fold && r.fold.state) || 'Answered'; by.set(st, (by.get(st) || 0) + 1) }
433
+ const keys = [...by.keys()].sort((a, b) => { const ia = FOLD_ORDER.indexOf(a), ib = FOLD_ORDER.indexOf(b); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) })
434
+ return keys.map((st) => ({ state: st, n: by.get(st), word: FOLD_WORD[st] || st.toLowerCase(), no: FOLD_NO.has(st) }))
435
+ }
436
+ export function askGroupLabel(rows) {
437
+ const n = (rows || []).length
438
+ const noun = (rows || []).every((r) => r.fold && r.fold.kind === 'question') ? (n === 1 ? 'question' : 'questions') : (n === 1 ? 'ask' : 'asks')
439
+ return [n + ' ' + noun, ...askGroupCounts(rows).map((c) => c.n + ' ' + c.word)].join(' · ')
440
+ }
441
+ export function askGroupHtml(rows, { esc = escHtml, openGroups = null } = {}) {
442
+ const key = askGroupKey(rows)
443
+ const open = !!(openGroups && openGroups.has && openGroups.has(key))
444
+ const id = 'lvag-' + key.replace(/[^A-Za-z0-9_-]/g, '')
445
+ let sum
446
+ if (rows.length === 1) {
447
+ const f = rows[0].fold || {}
448
+ const c = askGroupCounts(rows)[0]
449
+ sum = '<span class="lvag-what">' + esc(f.line || '') + '</span><span class="lvag-n' + (c.no ? ' lvag-no' : '') + '">' + esc(f.state || 'Answered') + '</span>'
450
+ } else {
451
+ const n = rows.length
452
+ const noun = rows.every((r) => r.fold && r.fold.kind === 'question') ? 'questions' : 'asks'
453
+ sum = '<span class="lvag-what">' + esc(n + ' ' + noun) + '</span>' + askGroupCounts(rows).map((c) => '<span class="lvag-sep" aria-hidden="true">·</span><span class="lvag-n' + (c.no ? ' lvag-no' : '') + '">' + esc(c.n + ' ' + c.word) + '</span>').join('')
454
+ }
455
+ const sig = sigHash(JSON.stringify([open, rows.map((r) => [r.key, r.html])]))
456
+ return '<li class="lv-askgroup' + (open ? ' open' : '') + '" data-key="' + esc(key) + '" data-sig="' + esc(sig) + '">' +
457
+ '<button type="button" class="lvag-toggle" data-action="ask-group" data-group="' + esc(key) + '" aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + esc(id) + '" aria-label="' + esc(askGroupLabel(rows) + (open ? ', shown' : ', tap to show')) + '">' +
458
+ '<span class="lvag-caret" aria-hidden="true">▸</span>' + sum + '</button>' +
459
+ '<ul class="lvag-list" id="' + esc(id) + '"' + (open ? '' : ' hidden') + '>' + rows.map((r) => r.html).join('') + '</ul></li>'
460
+ }
461
+ const escHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
462
+
311
463
  export function statusHtml(st, { esc, now = Date.now(), error = null }) {
312
464
  const v = statusView(st, now)
313
465
  if (!v) return ''
@@ -325,7 +477,7 @@ export { ranWithoutAsking }
325
477
  // A content signature, to replace only the entries that changed (the time-ago
326
478
  // text is left out so a minute ticking over doesn't redraw a reply).
327
479
  function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
328
- export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
480
+ export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.errorText]) }
329
481
 
330
482
  // ------------------------------------------------------------ the DOM keeper
331
483
 
@@ -376,7 +528,10 @@ export function createLiveKeeper() {
376
528
  // Claude's "Working" bubble isn't news by itself; the answer that replaces it is
377
529
  if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
378
530
  if (!cur.dataset.older) seenKept = true
531
+ const before = prev
379
532
  if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
533
+ // the same order as a fresh page: a row that moved (a turn's asks go before its reply) moves here too
534
+ if (before ? before.nextElementSibling !== prev : oldOl.firstElementChild !== prev) { if (before) before.after(prev); else oldOl.prepend(prev) }
380
535
  }
381
536
  for (const li of have.values()) li.remove()
382
537
  const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
package/ui/live-wake.js CHANGED
@@ -55,7 +55,7 @@ export function backoffMs(attempt) {
55
55
  export function liveSignature(msgs) {
56
56
  return (msgs || []).map((m) => {
57
57
  const st = m.status || {}
58
- return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0].join(':')
58
+ return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0, st.queued ? (st.cancellable ? 'qc' : 'q') : ''].join(':')
59
59
  }).join('|')
60
60
  }
61
61