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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/prs.js CHANGED
@@ -5,7 +5,7 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
5
5
  import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst, PROJECT_KEY } from './repos.js'
6
6
  import { renderDoc } from './handoffs.js'
7
7
  import { COPY_IC, DONE_IC, COPIED_TIP, COPIED_MS, copyIconClick } from './copy-icon.js'
8
- const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
8
+ export const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
9
9
 
10
10
  export function shortAgo(iso, now = Date.now()) {
11
11
  const t = Date.parse(iso || '')
@@ -93,24 +93,38 @@ export async function prCopyClick(btn, env = {}) {
93
93
  export function prMoreHtml(p, open = false) {
94
94
  return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
95
95
  }
96
+ // 2026-11-06 Walk me through this PR: the session's walkthrough opened on this PR's own range (src/walk-pr.js)
97
+ export const WALK_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"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>'
98
+ export { GH_MARK }
99
+ const OPEN_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="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
100
+ export const prWalkScope = (p) => (p && p.repo && p.number ? 'pr:' + p.repo + '#' + p.number : null)
101
+ // One Open button: a menu asking where (Walk me through it in Leftoff, or GitHub) when the PR has a walkthrough;
102
+ // else the plain GitHub link. (Joel, 2026-11-06: four glyphs took the title's room.)
103
+ export function prOpenHtml(p, walkId) {
104
+ const url = safeUrl(p.url), sc = walkId && prWalkScope(p), n = '#' + (p.number || '')
105
+ if (sc) return '<button type="button" class="pr-ic pr-open" data-action="pr-open" data-id="' + esc(walkId) + '" data-scope="' + esc(sc) + '" data-url="' + esc(url || '') + '" data-repo="' + esc(p.repo || '') + '" data-num="' + esc(p.number) + '" aria-haspopup="menu" data-tip="Open in Leftoff or on GitHub" aria-label="' + esc('Open ' + n + '…') + '">' + OPEN_IC + '</button>'
106
+ return url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : ''
107
+ }
96
108
  // 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
97
- export function prGhOnlyHtml(p) {
98
- const url = safeUrl(p.url)
99
- return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
109
+ // (2026-11-06 Open asks Leftoff or GitHub, when the row is a session's)
110
+ export function prGhOnlyHtml(p, { walkId = null } = {}) {
111
+ const open = prOpenHtml(p, walkId)
112
+ return open ? '<div class="pr-actions">' + open + '</div>' : ''
100
113
  }
101
- export function prActionsHtml(p, { unlinkId = null } = {}) {
114
+ export function prActionsHtml(p, { unlinkId = null, walkId = null } = {}) {
102
115
  const url = safeUrl(p.url), n = '#' + (p.number || '')
103
116
  if (!url && !unlinkId) return ''
104
117
  const copyLabel = 'Copy link to ' + n
105
118
  return '<div class="pr-actions">' +
106
119
  (url ? '<button type="button" class="pr-ic pr-copy" data-action="pr-copy" data-copy="' + esc(url) + '" data-tip="' + PR_COPY_TIP + '" data-label="' + esc(copyLabel) + '" aria-label="' + esc(copyLabel) + '">' + COPY_IC + DONE_IC + '</button>' : '') +
107
- (url ? '<a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc(n) + ' on GitHub">' + GH_MARK + '</a>' : '') +
120
+ prOpenHtml(p, walkId) +
108
121
  (unlinkId ? '<button type="button" class="pr-ic pr-unlink" data-action="pr-unlink" data-id="' + esc(unlinkId) + '" data-url="' + esc(p.url) + '" data-tip="Unlink from this session" aria-label="Unlink ' + esc(n) + ' from this session">' + UNLINK + '</button>' : '') +
109
122
  '</div>'
110
123
  }
111
124
 
112
125
  // The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
113
126
  export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
127
+ const walkId = s && !s.agent ? s.id : null
114
128
  // 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
115
129
  const more = (s.prsMore || []).filter((p) => p && p.number)
116
130
  const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
@@ -122,11 +136,13 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
122
136
  // the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
123
137
  h += '<div class="prlist pr-compact">' + list.map((p) => {
124
138
  const isOpen = !!(open && open.has && open.has(p.url))
125
- return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
139
+ // how many glyphs sit at the right (the title line leaves them room when open)
140
+ const acts = isOpen ? (safeUrl(p.url) ? 1 : 0) + (safeUrl(p.url) || (prWalkScope(p) && walkId) ? 1 : 0) + (mayEdit ? 1 : 0) : 0
141
+ return '<article class="prrow slim' + (isOpen ? ' open pr-acts-' + acts : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
126
142
  '<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
127
143
  '<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
128
- (isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
129
- (isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
144
+ (isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + (checksHtml(p.checks) ? ' · ' + checksHtml(p.checks) : '') + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
145
+ (isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null, walkId }) : prGhOnlyHtml(p, { walkId })) + '</article>'
130
146
  }).join('') + '</div>'
131
147
  }
132
148
  // several refs that couldn't be checked fold into one "+4 not checked" row
@@ -198,12 +214,14 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
198
214
  : [p.repo, p.branch, when].filter(Boolean)
199
215
  const why = whyText(r)
200
216
  const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
217
+ // 2026-11-06 Walk me through this PR: in its newest session's walkthrough (never a scripted run's)
218
+ const walkTo = !team ? (r.sessions || []).find((x) => x && x.id && !x.nonInteractive && x.archivedReason !== 'non_interactive' && !x.agent) : null
201
219
  return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
202
220
  '<div class="pr-line1">' + line1 + '</div>' +
203
221
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
204
222
  '<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
205
223
  sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
206
- prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null }) + '</article>'
224
+ prActionsHtml(p, { unlinkId: unlinkTo ? unlinkTo.id : null, walkId: walkTo ? walkTo.id : null }) + '</article>'
207
225
  }
208
226
 
209
227
  // ---------------------------------------------------------------- 0.8.5: details on tap
package/ui/pull.js CHANGED
@@ -9,10 +9,24 @@ export const PULL_MAX = 110 // how far the spinner travels at most
9
9
  // sideways scrollers, and anything open on top of the page.
10
10
  export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, .fs-side, [data-no-pull]'
11
11
 
12
- // The phone layout on a touch screen, at the very top of the page, from a
13
- // place that isn't a field or an overlay, and with no overlay open.
12
+ // 2026-11-06 ("it's really hard to do that when you're at the bottom of a long message chain, because
13
+ // you have to scroll all the way to the top"): the bars at the top of the screen are a handle of their
14
+ // own. A pull that starts on the top row, a session's head or the Live box's head refreshes wherever
15
+ // the page or the chat is scrolled to. Fields, menus, sideways scrollers and the sheets still never
16
+ // start one (PULL_HEAD_SKIP: PULL_SKIP without the Live box, whose head is one of the bars).
17
+ export const PULL_HEAD = 'header.top, .s-head, .fs-head, .live-head, .live-fbar' // .live-fbar: full-screen Live's slim bar
18
+ export const PULL_HEAD_SKIP = PULL_SKIP.split(', ').filter((x) => x !== '.live-box' && x !== 'pre').join(', ') + ', button.chip-x, [role="slider"]'
19
+ export function onPullHead(target) {
20
+ if (!target || typeof target.closest !== 'function') return false
21
+ return !!target.closest(PULL_HEAD) && !target.closest(PULL_HEAD_SKIP)
22
+ }
23
+
24
+ // The phone layout on a touch screen, at the very top of the page (or on one of
25
+ // the bars at the top, onPullHead), from a place that isn't a field or an
26
+ // overlay, and with no overlay open.
14
27
  export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } = {}) {
15
28
  if (!coarse || !narrow || overlayOpen) return false
29
+ if (onPullHead(target)) return true
16
30
  if (scrollY > 0) return false
17
31
  if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
18
32
  if (scrolledAbove(target)) return false
@@ -364,7 +364,9 @@ class RelayEventSource extends EventTarget {
364
364
  if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
365
365
  this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
366
366
  const first = this.seq == null
367
+ const lost = !first && (j.reset || j.gap) // 2026-11-06: events this browser never got (the Mac restarted, another took over, or more happened than it keeps)
367
368
  this.seq = j.seq
369
+ if (lost) this.dispatchEvent(new MessageEvent('resync', { data: JSON.stringify({ reset: !!j.reset, gap: !!j.gap }) }))
368
370
  if (!first) for (const e of j.events || []) {
369
371
  const ev = new MessageEvent(e.event, { data: JSON.stringify(e.data) })
370
372
  this.dispatchEvent(ev)
package/ui/semdiff.js CHANGED
@@ -371,11 +371,25 @@ function fileMapHtml(data, { esc, icon, cur }) {
371
371
  return h + '<div class="wt-fm-k"><span class="kbd">j</span><span class="kbd">k</span> next step</div></nav>'
372
372
  }
373
373
 
374
+ // 2026-11-06 every PR the session has is its own range ('pr:<owner/repo>#<n>'): up to PR_CHIPS of them are
375
+ // chips; more fold into one "PRs" chip that opens a menu (the one picked shows its number)
376
+ export const PR_CHIPS = 2
377
+ export const isPrKey = (k) => k === 'pr' || /^pr:/.test(String(k || ''))
378
+ const PR_WORD = { merged: 'merged', closed: 'closed', draft: 'draft' }
379
+ export function prScopeTip(x) { return [x.title, x.repo, x.state ? (x.state === 'open' ? 'Open' : x.state[0].toUpperCase() + x.state.slice(1)) : ''].filter(Boolean).join(' · ') }
374
380
  function scopesHtml(data, scope, { esc, icon, sid }) {
375
381
  const sc = (data && data.scopes) || [{ key: 'checkpoint', label: SCOPE_WORDS.checkpoint, available: false }, { key: 'session', label: SCOPE_WORDS.session, available: true }]
376
- return '<div class="wt-seg" role="group" aria-label="What to walk through">' + sc.map((x) =>
377
- '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : '') + '>' +
378
- (x.key === 'pr' ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + '</span></button>').join('') + '</div>'
382
+ const prs = sc.filter((x) => isPrKey(x.key)), rest = sc.filter((x) => !isPrKey(x.key))
383
+ const chip = (x) => '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : isPrKey(x.key) && prScopeTip(x) ? ' data-tip="' + esc(prScopeTip(x)) + '"' : '') + '>' +
384
+ (isPrKey(x.key) ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + (PR_WORD[x.state] ? ' · ' + PR_WORD[x.state] : '') + '</span></button>'
385
+ let h = rest.map(chip).join('')
386
+ if (prs.length <= PR_CHIPS) h += prs.map(chip).join('')
387
+ else {
388
+ const cur = prs.find((x) => x.key === scope)
389
+ h += '<button type="button" data-action="wt-prs" data-id="' + esc(sid) + '" aria-pressed="' + (cur ? 'true' : 'false') + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc(cur ? cur.label + ', pick another of ' + prs.length + ' PRs' : 'Pick one of ' + prs.length + ' PRs') + '"' + (cur && prScopeTip(cur) ? ' data-tip="' + esc(prScopeTip(cur)) + '"' : '') + '>' +
390
+ icon('git-pull-request') + '<span>' + esc(cur ? cur.label + (PR_WORD[cur.state] ? ' · ' + PR_WORD[cur.state] : '') : 'PRs · ' + prs.length) + '</span>' + icon('chevron-down', 'ic wt-seg-chev') + '</button>'
391
+ }
392
+ return '<div class="wt-seg" role="group" aria-label="What to walk through">' + h + '</div>'
379
393
  }
380
394
 
381
395
  // 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
@@ -464,7 +478,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
464
478
  if (ui.loading) h += '<p class="wt-sum wt-loading" role="status"><span class="wt-spin" aria-hidden="true"></span><span>Reading the change' + (data && data.status === 'ok' ? '' : '…') + '</span><button type="button" class="linkish wt-stop" data-action="wt-stop" data-id="' + esc(sid) + '">Stop</button></p>'
465
479
  if (ui.error) h += '<p class="wt-note" role="alert">' + icon('circle-alert') + '<span>' + esc(ui.error) + '</span></p>'
466
480
  if (data && !ui.loading && data.status !== 'ok' && data.message) {
467
- const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope === 'pr' ? 'No changes in this PR yet.' : data.message
481
+ const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && isPrKey(scope) ? 'No changes in this PR yet.' : data.message
468
482
  h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
469
483
  h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
470
484
  // a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
@@ -473,7 +487,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
473
487
  if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
474
488
  }
475
489
  // 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
476
- if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
490
+ if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (isPrKey(ui.scope) ? 'No walkthrough of this PR yet.' : data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
477
491
  const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
478
492
  if (ok && view === 'story' && !deck) {
479
493
  const steps = data.steps || []
@@ -562,7 +576,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
562
576
  let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
563
577
  // the overview
564
578
  const st = data.stats || {}
565
- const scope = SCOPE_WORDS[ui.scope] || ''
579
+ const scope = SCOPE_WORDS[ui.scope] || (((data.scopes || []).find((x) => x.key === ui.scope) || {}).label) || ''
566
580
  const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
567
581
  let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
568
582
  o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'
@@ -0,0 +1,41 @@
1
+ // 2026-11-06 subagents working, on the board (src/subagents.js reads them): the session's status says
2
+ // "2 subagents working" instead of looking idle, and Live shows a line with what the newest one is
3
+ // doing ("Bash: pnpm test"). Words and a shape (the spinner), never color alone.
4
+
5
+ export function subCount(s) { const n = s && s.subagents ? Number(s.subagents.n) || 0 : 0; return n > 0 ? n : 0 }
6
+ export function subLabel(sub) {
7
+ const n = sub ? Number(sub.n) || 0 : 0
8
+ return n > 0 ? n + (n === 1 ? ' subagent working' : ' subagents working') : ''
9
+ }
10
+ export function subStep(sub) {
11
+ const l = sub && sub.latest
12
+ if (!l) return ''
13
+ return String(l.tool ? l.tool + (l.detail ? ': ' + l.detail : '') : l.detail || '').replace(/\s+/g, ' ').trim().slice(0, 160)
14
+ }
15
+ const who = (a) => (a && (a.label || (a.type ? a.type + ' agent' : ''))) || 'a subagent'
16
+ export function subTip(sub) {
17
+ const list = (sub && Array.isArray(sub.agents) ? sub.agents : []).map((a) => who(a) + (a && a.bg ? ' (background)' : ''))
18
+ const step = subStep(sub)
19
+ return (list.length ? list.join('\n') : subLabel(sub)) + (step ? '\nNow: ' + step : '')
20
+ }
21
+ // The status pill while subagents work (a session in a turn or with background work)
22
+ export function subPillHtml(s, esc) {
23
+ const sub = s && s.subagents
24
+ const label = subLabel(sub)
25
+ if (!label) return ''
26
+ const tip = subTip(sub)
27
+ return '<span class="pill s-active sub-pill" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(label + ': ' + tip.replace(/\n/g, ', ')) + '"><span class="sub-spin" aria-hidden="true"></span>' + esc(label) + '</span>'
28
+ }
29
+ // Live's line under the thread
30
+ export function subStripHtml(sub, esc, { now = Date.now() } = {}) {
31
+ const label = subLabel(sub)
32
+ if (!label) return ''
33
+ const step = subStep(sub)
34
+ const at = sub.latest && sub.latest.at ? Number(sub.latest.at) : 0
35
+ const ago = at ? Math.max(0, Math.round((now - at) / 1000)) : null
36
+ const when = ago == null ? '' : ago < 5 ? 'just now' : ago < 90 ? ago + 's ago' : Math.round(ago / 60) + 'm ago'
37
+ return '<div class="lv-subs" role="status" data-tip="' + esc(subTip(sub)) + '"><span class="sub-spin" aria-hidden="true"></span><span class="lv-subs-what"><b>' + esc(label) + '</b>' +
38
+ (step ? '<span class="lv-subs-step">' + esc(step) + '</span>' : '') + '</span>' + (when ? '<span class="faint lv-subs-when">' + esc(when) + '</span>' : '') + '</div>'
39
+ }
40
+ // What Live's refetch compares, beside the thread's own signature
41
+ export function subSig(sub) { return sub && sub.n > 0 ? sub.n + ':' + (sub.latest ? sub.latest.at + (sub.latest.tool || '') + (sub.latest.detail || '') : '') : '' }
@@ -22,13 +22,14 @@ export function isTypingTarget(el) {
22
22
  export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
23
23
 
24
24
  // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
- // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
25
+ // accessory: px a keyboard bar drawn over the page covers (keyboardAccessoryPx).
26
+ // -> { h, top, kb, pad? } in CSS px (pad only when there's room to keep) (null without a visualViewport: the CSS falls back to 100dvh).
26
27
  // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
28
  // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
29
  // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
30
  // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
31
  // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
31
- export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
32
+ export function fullViewportFit({ innerHeight, vv, typing = false, accessory = 0 } = {}) {
32
33
  if (!vv || !(Number(innerHeight) > 0)) return null
33
34
  const ih = Number(innerHeight)
34
35
  const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
@@ -37,15 +38,32 @@ export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
37
38
  if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
38
39
  const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
39
40
  const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
40
- return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
41
+ // pad: room kept clear at the bottom for a keyboard bar drawn over the page (iOS 26's form bar), only with a keyboard up
42
+ const pad = kb ? Math.max(0, Math.round(Number(accessory) || 0)) : 0
43
+ const fit = { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
44
+ if (pad) fit.pad = pad
45
+ return fit
46
+ }
47
+
48
+ // 2026-11-06 (Joel, iPhone: "So close but not quite": the sheet stepped aside, but only the reply
49
+ // box's top edge showed above the keyboard's ^ v ✓ bar). Since iOS 26 that bar floats over the
50
+ // page (Liquid Glass: it and the band behind it show the page through), and the visual viewport iOS
51
+ // reports runs down behind it, so a reply box placed at the reported bottom sits under the bar. No
52
+ // browser number says how tall the bar is, so on an iPhone or iPad whose Safari is 26 or later
53
+ // (glass: a CSS feature that arrived in Safari 26) full screen keeps this much clear at its bottom
54
+ // while a keyboard is up. On older iOS the bar was outside the reported viewport: nothing kept.
55
+ // Measured from the screenshot: the band from the bar's top edge to the keys is about 68px.
56
+ export const ACCESSORY_PX = 64
57
+ export function keyboardAccessoryPx({ ios = false, glass = false } = {}) {
58
+ return ios && glass ? ACCESSORY_PX : 0
41
59
  }
42
60
 
43
61
  // How far full screen's composer sits outside what you can see (the visual viewport, in the same
44
62
  // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
45
63
  // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
46
- export function outOfView(rect, vv, slack = 1) {
64
+ export function outOfView(rect, vv, slack = 1, pad = 0) {
47
65
  if (!rect || !vv || !(Number(vv.height) > 0)) return 0
48
- const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
66
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height) - Math.max(0, Number(pad) || 0) // pad: covered by a keyboard bar
49
67
  if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
50
68
  if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
51
69
  return 0
@@ -57,6 +75,10 @@ export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
57
75
  // The same beats after every report while a text box has focus: a keyboard swap's last change can
58
76
  // come with no event of its own, so the board looks again a few times after each one.
59
77
  export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
78
+ // And every frame for this long after the box takes focus and after each report: iOS animates the
79
+ // keyboard (and pans) for ~0.3–0.5 s, part of it with no event, and its numbers settle a frame or
80
+ // two after the event that announced them (WebKit bug 237851: offsetTop read 0 mid-animation).
81
+ export const FOLLOW_FRAMES_MS = 1000
60
82
 
61
83
  // 2026-11-06 (the phone's session sheet covered the reply box while typing; "sometimes I have to
62
84
  // scroll that down, and then the message box appears"): the sheet went away only when the board