@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/CHANGELOG.md +6 -0
- package/bin/leftoff.js +600 -591
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +34 -2
- package/ui/app.js +141 -35
- package/ui/live-wake.js +47 -0
- package/ui/prs.js +28 -10
- package/ui/pull.js +16 -2
- package/ui/relay-client.js +2 -0
- package/ui/semdiff.js +20 -6
- package/ui/subagents-view.js +41 -0
- package/ui/viewport-fit.js +27 -5
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
package/ui/relay-client.js
CHANGED
|
@@ -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
|
-
|
|
377
|
-
|
|
378
|
-
(x.key
|
|
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
|
|
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 || '') : '') : '' }
|
package/ui/viewport-fit.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|