@leftoff/cli 0.9.8-beta.10 → 0.9.8-beta.12

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/app.js CHANGED
@@ -12,6 +12,10 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
12
12
  import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
13
13
  import { canStartPull, pullStep, pullRelease } from './pull.js'
14
14
  import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
15
+ import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
16
+ import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
17
+ import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
18
+ import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
15
19
  import { wireTips } from './tips.js'
16
20
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
17
21
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
@@ -1319,11 +1323,34 @@ function sessionShellHtml(s) {
1319
1323
  '<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
1320
1324
  '<div class="skel-line"></div><div class="skel-line short"></div><div class="skel-block"></div><div class="skel-line"></div><div class="skel-line short"></div></div>'
1321
1325
  }
1326
+ // A selector that finds the same control again after a redraw (null: it can't be told apart).
1327
+ function focusKey(el) {
1328
+ if (!el || !el.tagName) return null
1329
+ if (el.id) return '#' + CSS.escape(el.id)
1330
+ const d = el.dataset || {}
1331
+ const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
1332
+ if (!keys.length) return null
1333
+ const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
1334
+ return el.tagName.toLowerCase() + keys.map(attr).join('')
1335
+ }
1322
1336
  function paintSession(s, data) {
1323
1337
  if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1338
+ // a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
1339
+ const fa = document.activeElement
1340
+ const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
1341
+ // …and any other control that had focus (a chip, a thumbnail, a button: a
1342
+ // preview closed with Esc puts focus back on one, then a redraw replaced it)
1343
+ const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
1324
1344
  liveKeeper.before($app)
1325
1345
  $app.innerHTML = sessionPageHtml(s, data)
1326
1346
  liveAfterRender(s.id)
1347
+ if (kf) {
1348
+ const nt = $app.querySelector('.composer-input')
1349
+ if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
1350
+ } else if (kb && !$app.contains(document.activeElement)) {
1351
+ const hits = $app.querySelectorAll(kb)
1352
+ if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
1353
+ }
1327
1354
  aroundWire(s.id)
1328
1355
  }
1329
1356
  async function viewSession(id) {
@@ -1461,17 +1488,343 @@ function liveBoxHtml(s, v) {
1461
1488
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1462
1489
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1463
1490
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1464
- earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
1491
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1465
1492
  '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1466
1493
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1467
1494
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1468
- '<div class="reply-row live-row"><textarea class="reply-text live-text" rows="2" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter') + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1469
- '<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
1495
+ composerHtml(s, draft, may) +
1470
1496
  (may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy &amp; security → Trust my paired devices.</p>') + '</section>'
1471
1497
  }
1498
+ // The Live composer: one rounded box, a textarea that starts at one line and
1499
+ // grows, and a round ↑ in its corner (dim until there's something to send).
1500
+ // No interrupt reaches Claude Code from the board, so while it works the
1501
+ // button still sends: the message waits for its next turn, as before.
1502
+ 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>'
1503
+ 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>'
1504
+ const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
1505
+ function composerHtml(s, draft, may) {
1506
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1507
+ const atts = attachList(s.id)
1508
+ const off = !may || (isBlank(draft) && !atts.length)
1509
+ return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1510
+ '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1511
+ '<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1512
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1513
+ '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1514
+ '<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
1515
+ '<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
1516
+ }
1517
+
1518
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1519
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1520
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1521
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1522
+ const attachList = (sid) => attachUi.by.get(sid) || []
1523
+ function chipHtml(a) {
1524
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1525
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1526
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1527
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1528
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1529
+ '<span class="att-meta"><span class="att-name" title="' + esc(a.name) + '">' + esc(shortName(a.name)) + '</span><span class="att-size">' + esc(status) + '</span></span></button>' +
1530
+ (a.status === 'failed' ? '<button type="button" class="att-btn att-retry" data-action="att-retry" data-lid="' + esc(a.lid) + '" aria-label="Retry ' + esc(a.name) + '" title="Retry">↻</button>' : '') +
1531
+ '<button type="button" class="att-btn att-x" data-action="att-remove" data-lid="' + esc(a.lid) + '" aria-label="Remove ' + esc(a.name) + '" title="Remove">×</button>' +
1532
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1533
+ }
1534
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1535
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1536
+ // Redraw one session's chips in place (and the ↑'s state).
1537
+ function paintChips(sid) {
1538
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1539
+ if (!box) return
1540
+ const atts = attachList(sid)
1541
+ const wrap = box.querySelector('.att-chips')
1542
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1543
+ wrap.innerHTML = atts.map(chipHtml).join('')
1544
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1545
+ barWidths(wrap)
1546
+ wrap.hidden = !atts.length
1547
+ box.classList.toggle('has-atts', !!atts.length)
1548
+ const ta = box.querySelector('.composer-input')
1549
+ if (ta) syncComposer(ta)
1550
+ }
1551
+ function addFiles(sid, files, { pasted = false } = {}) {
1552
+ const list = Array.from(files || []).filter(Boolean)
1553
+ if (!sid || !list.length) return
1554
+ const have = attachList(sid)
1555
+ const plan = attachPlan(list, have.length)
1556
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1557
+ const add = plan.take.map((f) => {
1558
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1559
+ return { lid: 'a' + (++attachUi.seq), sid, file, name: file.name || 'file', size: file.size, type: file.type || '', kind: attachKind(file), status: 'prep', sent: 0, total: file.size, id: null, preview: null, thumb: null, error: null }
1560
+ })
1561
+ if (!add.length) return
1562
+ attachUi.by.set(sid, [...have, ...add])
1563
+ paintChips(sid)
1564
+ try { promoteActive() } catch {}
1565
+ for (const a of add) startUpload(a)
1566
+ }
1567
+ function startUpload(a) {
1568
+ a.status = 'prep'; a.error = null; a.sent = 0
1569
+ attachUi.queue.push(a); pumpUploads()
1570
+ }
1571
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1572
+ function pumpUploads() {
1573
+ while (attachUi.running < 3 && attachUi.queue.length) {
1574
+ const a = attachUi.queue.shift()
1575
+ if (!attachList(a.sid).includes(a)) continue
1576
+ attachUi.running++
1577
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1578
+ }
1579
+ }
1580
+ async function runUpload(a) {
1581
+ const gone = () => !attachList(a.sid).includes(a)
1582
+ try {
1583
+ if (!a.prepared) {
1584
+ const p = await prepareFile(a.file)
1585
+ a.prepared = p.file; a.thumb = p.thumb
1586
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1587
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1588
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1589
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1590
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1591
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1592
+ }
1593
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1594
+ }
1595
+ if (gone()) return
1596
+ a.status = 'up'; paintChips(a.sid)
1597
+ let last = 0
1598
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1599
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1600
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1601
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1602
+ } catch (e) {
1603
+ if (e.code === 'removed' || gone()) return
1604
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1605
+ }
1606
+ paintChips(a.sid)
1607
+ attachWaiters()
1608
+ }
1609
+ let attachWaitList = []
1610
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1611
+ // Wait until nothing of this session's is still uploading.
1612
+ async function uploadsSettled(sid) {
1613
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1614
+ }
1615
+ function removeAttachment(lid) {
1616
+ for (const [sid, list] of attachUi.by) {
1617
+ const a = list.find((x) => x.lid === lid)
1618
+ if (!a) continue
1619
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1620
+ paintChips(sid)
1621
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1622
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1623
+ return
1624
+ }
1625
+ }
1626
+ function retryAttachment(lid) {
1627
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1628
+ }
1629
+ // The composer a drop or a paste is for: the session page's own (enabled).
1630
+ function composerFor(el) {
1631
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1632
+ return box && !box.classList.contains('off') ? box : null
1633
+ }
1634
+ document.addEventListener('change', (ev) => {
1635
+ const inp = ev.target
1636
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1637
+ addFiles(inp.dataset.sid, inp.files)
1638
+ inp.value = '' // the same file can be picked again
1639
+ })
1640
+ document.addEventListener('paste', (ev) => {
1641
+ const t = ev.target
1642
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1643
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1644
+ const cd = ev.clipboardData
1645
+ const files = cd ? Array.from(cd.files || []).length ? Array.from(cd.files) : Array.from(cd.items || []).filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean) : []
1646
+ if (!files.length) return
1647
+ const box = composerFor(inComposer ? t : null)
1648
+ if (!box) return
1649
+ ev.preventDefault()
1650
+ addFiles(box.dataset.sid, files, { pasted: true })
1651
+ })
1652
+ {
1653
+ // Drag files onto the session page: the box lights up; drop attaches.
1654
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1655
+ let leaveT = 0
1656
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1657
+ document.addEventListener('dragover', (e) => { if (!hasFiles(e) || !composerFor(null)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; lit(true); clearTimeout(leaveT); leaveT = setTimeout(() => lit(false), 300) })
1658
+ document.addEventListener('drop', (e) => {
1659
+ if (!hasFiles(e)) return
1660
+ const box = composerFor(null)
1661
+ e.preventDefault() // never let a stray drop navigate away from the board
1662
+ lit(false)
1663
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1664
+ })
1665
+ }
1666
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1667
+ function attBubbleHtml(atts, sid) {
1668
+ return '<div class="lv-atts">' + atts.map((a) => {
1669
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1670
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1671
+ const data = ' data-action="att-open" data-sid="' + esc(s0) + '" data-att="' + esc(a.id) + '" data-name="' + esc(a.name) + '" data-kind="' + esc(a.kind) + '" data-thumb="' + (a.thumb === false ? '0' : '1') + '" data-size="' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '"'
1672
+ if (a.kind === 'image') { const u = attachUi.urls.get(key); return '<button type="button" class="lv-att lv-att-img"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><img alt="' + esc(a.name) + '" data-att-thumb="' + esc(key) + '"' + (u ? ' src="' + esc(u) + '"' : '') + '></button>' }
1673
+ const firstPage = a.kind === 'pdf' && a.thumb !== false ? '<img alt="" data-att-thumb="' + esc(key) + '"' + (attachUi.urls.get(key) ? ' src="' + esc(attachUi.urls.get(key)) + '"' : '') + '>' : '' // its first page, when there is one
1674
+ return '<button type="button" class="lv-att lv-att-file"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><span class="att-thumb' + (firstPage ? ' att-thumb-pdf' : '') + '">' + FILE_IC + firstPage + '</span><span class="att-meta"><span class="att-name">' + esc(a.name) + '</span><span class="att-size">' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '</span></span></button>'
1675
+ }).join('') + '</div>'
1676
+ }
1677
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1678
+ // board's CSP allows images from itself and data: only (no blob:).
1679
+ const blobToDataUrl = (b) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('unreadable')); r.readAsDataURL(b) })
1680
+ function attData(sid, id, { thumb = false } = {}) {
1681
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1682
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1683
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1684
+ const p = fetchAttachment({ api, sessionId: sid, id, thumb }).then(async (b) => { const url = await blobToDataUrl(b); attachUi.urls.set(key, url); attachUi.blobs.set(key, b); return { url, blob: b } }).finally(() => attachUi.loading.delete(key))
1685
+ attachUi.loading.set(key, p)
1686
+ return p
1687
+ }
1688
+ function hydrateAttThumbs() {
1689
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1690
+ const key = img.dataset.attThumb
1691
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1692
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1693
+ btn.dataset.loading = '1'
1694
+ const thumb = btn.dataset.thumb !== '0'
1695
+ attData(btn.dataset.sid, btn.dataset.att, { thumb }).then(({ url }) => { attachUi.urls.set(key, url); for (const i of document.querySelectorAll('#app img[data-att-thumb="' + CSS.escape(key) + '"]')) i.src = url })
1696
+ .catch((e) => {
1697
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1698
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1699
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1700
+ })
1701
+ }
1702
+ }
1703
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1704
+ // same message beside it; the thumbnail shows (blurred) while the full one
1705
+ // comes from the Mac. Text and other files keep the docs viewer.
1706
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1707
+ function openImageAttachment(btn) {
1708
+ const group = btn.closest('.lv-atts')
1709
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1710
+ const items = btns.map((b) => {
1711
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1712
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1713
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1714
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1715
+ })
1716
+ const sid = btn.dataset.sid, id = btn.dataset.att
1717
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1718
+ }
1719
+ // A chip in the box before it's sent: the picture from the file you picked
1720
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1721
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1722
+ function openChip(lid) {
1723
+ let a = null
1724
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1725
+ if (!a) return
1726
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1727
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1728
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1729
+ if (a.kind === 'pdf') {
1730
+ const f = a.prepared || a.file
1731
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1732
+ return
1733
+ }
1734
+ if (isImg(a)) {
1735
+ const list = attachList(a.sid).filter(isImg)
1736
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1737
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1738
+ openLightbox({ items, index: Math.max(0, list.indexOf(a)), from: (chip && chip.querySelector('img')) || chip, refocus: () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]') })
1739
+ return
1740
+ }
1741
+ const el = docViewerEl()
1742
+ el.querySelector('.dv-title').textContent = a.name
1743
+ el.querySelector('.dv-sub').textContent = ''
1744
+ el.querySelector('.dv-dl').hidden = true
1745
+ docUi.r = null; docUi.name = a.name
1746
+ el.querySelector('.dv-body').innerHTML = '<div class="empty att-chip-info"><b>' + esc(a.name) + '</b><br>' + esc(fmtSize(a.size)) + (a.status === 'done' ? ' · goes with your next message' : a.status === 'failed' ? ' · didn’t upload' : ' · uploading') + '</div>'
1747
+ el.hidden = false
1748
+ document.documentElement.classList.add('dv-open')
1749
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1750
+ }
1751
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1752
+ // from the file the Mac sends back (the same chunked read as a picture).
1753
+ function openPdfAttachment(btn) {
1754
+ const sid = btn.dataset.sid, id = btn.dataset.att
1755
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1756
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1757
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1758
+ }
1759
+ async function openAttachment(btn) {
1760
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1761
+ if (kind === 'image') return openImageAttachment(btn)
1762
+ if (kind === 'pdf') return openPdfAttachment(btn)
1763
+ const el = docViewerEl()
1764
+ el.querySelector('.dv-title').textContent = name
1765
+ el.querySelector('.dv-sub').textContent = ''
1766
+ el.querySelector('.dv-dl').hidden = true
1767
+ const body = el.querySelector('.dv-body')
1768
+ body.innerHTML = '<div class="empty">Opening…</div>'
1769
+ docUi.r = null; docUi.name = name
1770
+ el.hidden = false
1771
+ document.documentElement.classList.add('dv-open')
1772
+ try {
1773
+ const { url: u, blob } = await attData(sid, id)
1774
+ if (el.hidden) return
1775
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1776
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1777
+ else if (kind === 'text') { const t = blob ? await blob.text() : ''; body.innerHTML = '<pre class="att-text">' + esc(t.slice(0, 200000)) + '</pre><div class="att-full-actions">' + dl + '</div>' }
1778
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1779
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1780
+ }
1781
+ // Fit the box to its words (one line up to COMPOSER_MAX_LINES, then it scrolls) and light the ↑ when there's something to send.
1782
+ function fitComposer(ta) {
1783
+ if (!ta || !ta.isConnected) return
1784
+ const cs = getComputedStyle(ta)
1785
+ const lh = parseFloat(cs.lineHeight) || 22
1786
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1787
+ const keep = ta.scrollTop
1788
+ ta.style.height = 'auto'
1789
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1790
+ ta.style.height = r.height + 'px'
1791
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1792
+ if (r.scroll) ta.scrollTop = keep
1793
+ syncComposer(ta)
1794
+ }
1795
+ function syncComposer(ta) {
1796
+ const box = ta && ta.closest('.composer')
1797
+ const btn = box && box.querySelector('.composer-send')
1798
+ if (!btn || btn.classList.contains('sending')) return
1799
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1800
+ }
1801
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1802
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1803
+ document.addEventListener('keydown', (ev) => {
1804
+ const ta = ev.target
1805
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1806
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1807
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1808
+ ev.preventDefault()
1809
+ const cb = ta.closest('.composer')
1810
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1811
+ const box = ta.closest('.live-box')
1812
+ const btn = box && box.querySelector('[data-submit-for="live"]')
1813
+ if (!btn || btn.disabled) return
1814
+ const last = Number(box.dataset.kbdAt || 0)
1815
+ if (Date.now() - last < 1500) return
1816
+ box.dataset.kbdAt = String(Date.now())
1817
+ btn.click()
1818
+ })
1819
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1820
+ document.addEventListener('click', (ev) => { const b = ev.target && ev.target.classList && ev.target.classList.contains('composer') ? ev.target : null; const ta = b && b.querySelector('.composer-input'); if (ta && !ta.disabled) ta.focus() })
1821
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1822
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.composer-send') && document.activeElement && document.activeElement.classList.contains('composer-input')) ev.preventDefault() })
1472
1823
  // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1473
1824
  function liveAfterRender(sid) {
1825
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1474
1826
  const r = liveKeeper.after($app)
1827
+ hydrateAttThumbs(); barWidths($app)
1475
1828
  if (!r) return
1476
1829
  const ol = r.thread.querySelector('.live-log')
1477
1830
  const empty = r.thread.querySelector('.live-empty')
@@ -1599,6 +1952,7 @@ async function refreshLive(reason) {
1599
1952
  liveKeeper.before(box)
1600
1953
  box.insertBefore(fresh, next)
1601
1954
  const r = liveKeeper.after(box)
1955
+ hydrateAttThumbs()
1602
1956
  if (r) liveJumpButton(r.thread, liveKeeper.unseen)
1603
1957
  }
1604
1958
  }
@@ -1645,19 +1999,34 @@ document.addEventListener('visibilitychange', onWake)
1645
1999
  async function liveSendClick(btn) {
1646
2000
  const box = btn.closest('.live-box')
1647
2001
  const ta = box && box.querySelector('.live-text')
2002
+ const sid = btn.dataset.id
1648
2003
  const text = ta ? ta.value.trim() : ''
1649
- if (!text) { toast('Type a message first'); return }
1650
- if (btn.getAttribute('aria-disabled') === 'true') return
1651
- btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
2004
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2005
+ if (btn.disabled || btn.classList.contains('sending')) return
2006
+ const hadFocus = !!(ta && document.activeElement === ta)
2007
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1652
2008
  try {
1653
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1654
- if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1655
- if (ta) ta.value = ''
2009
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2010
+ await uploadsSettled(sid)
2011
+ const atts = attachList(sid)
2012
+ const failed = atts.filter((a) => a.status === 'failed').length
2013
+ if (failed) { toast(failed === 1 ? 'A file didn’t upload: retry it or remove it.' : failed + ' files didn’t upload: retry or remove them.', 4200); return }
2014
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2015
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2016
+ // what you typed while it went stays; what was sent goes
2017
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2018
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1656
2019
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1657
2020
  const ol = box && box.querySelector('.live-log')
1658
2021
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1659
2022
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1660
- } catch (e) { toast(e.message, 4200) } finally { await render(); const nb = document.querySelector('.live-box [data-action="live-send"]'); if (nb) { nb.removeAttribute('aria-disabled'); nb.textContent = 'Send' } }
2023
+ } catch (e) { toast(e.message, 4200) } finally {
2024
+ await render()
2025
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2026
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2027
+ const nt = document.querySelector('.live-box .composer-input')
2028
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2029
+ }
1661
2030
  }
1662
2031
 
1663
2032
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -3970,7 +4339,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
3970
4339
  sw = null
3971
4340
  if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
3972
4341
  const t = e.target
3973
- if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
4342
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
3974
4343
  sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
3975
4344
  }, { passive: true })
3976
4345
  document.addEventListener('touchend', (e) => {
@@ -4035,7 +4404,7 @@ function keepPlace() {
4035
4404
  ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
4036
4405
  document.body.appendChild(ptr)
4037
4406
  const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
4038
- const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
4407
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap')
4039
4408
  let pull = null, refreshing = false
4040
4409
  const paint = (step) => {
4041
4410
  ptr.hidden = !step || (!step.pulling && !refreshing)
@@ -4097,7 +4466,7 @@ function keepPlace() {
4097
4466
  const top = document.querySelector('.top')
4098
4467
  let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4099
4468
  const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4100
- const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
4469
+ const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
4101
4470
  const apply = (hidden) => {
4102
4471
  if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4103
4472
  document.body.classList.toggle('nav-hidden', hidden)
@@ -4786,6 +5155,11 @@ document.addEventListener('click', async (ev) => {
4786
5155
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4787
5156
  if (action === 'reply-send') return replyClick(btn)
4788
5157
  if (action === 'live-send') return liveSendClick(btn)
5158
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5159
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5160
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5161
+ if (action === 'att-open') return openAttachment(btn)
5162
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
4789
5163
  if (action === 'live-jump') return liveJumpClick(btn)
4790
5164
  if (action === 'live-earlier') return liveEarlierClick(btn)
4791
5165
  if (action === 'quick-reply') return quickReplyClick(btn)